mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ae05e7cd4 | |||
| b284176072 | |||
| 5fffaf2f4e | |||
| 58da91eae8 | |||
| 98d92d3fe2 | |||
| c5d35b27f0 | |||
| aee5b75c2f | |||
| fe02197bd8 | |||
| a1060a15be | |||
| dc7b2f420b | |||
| b35390a4bb | |||
| 3b253ad2b6 | |||
| 06c39ce973 | |||
| 11b8914615 | |||
| e45c8617df | |||
| 9075a2599c | |||
| dd8d50e0e2 | |||
| 55a11d82ac | |||
| 7ee4f411cb | |||
| c9d5508018 | |||
| bef8fc682c | |||
| c37d464403 | |||
| cbf2712654 | |||
| 08134b4427 | |||
| f90290c4ec | |||
| 7cde8ed538 | |||
| 585cf090d6 | |||
| 23d1085755 | |||
| fc5888d57e | |||
| c5541b1747 | |||
| 0fd8ba28bb | |||
| 6d9f4959e0 | |||
| 4be4bb631f | |||
| 948f5a2a6d | |||
| baad342aec | |||
| aeb29c54cd | |||
| a5dfa653ad | |||
| 3387c727d1 | |||
| c806ff2e33 | |||
| 1db4d427fc | |||
| 3bf73ed5e8 | |||
| db44aa2c4d | |||
| 0e6e381800 | |||
| 69e43dc533 | |||
| ee4442d553 | |||
| c49b9f7841 | |||
| 8a35cd0e82 | |||
| 0ae90ecf03 | |||
| 3d2840fe15 | |||
| b6ad3fd991 | |||
| 2ee3c30b0e | |||
| 662e94bcee | |||
| f3c9e0196e | |||
| f15eb9bf9e | |||
| 12b2552185 | |||
| d245e20b14 | |||
| e47349d010 | |||
| eb3dd854d4 | |||
| c529446219 | |||
| fa2f8c3447 | |||
| 840d1ae534 | |||
| 2530c6eb58 | |||
| 869789f0e2 | |||
| ee3761c780 | |||
| e4c89e9aa9 | |||
| 9d5888ddf7 | |||
| ecc94920d7 | |||
| 5cafbf243e | |||
| f3fa208680 | |||
| 502602e27a | |||
| 5054b093a4 | |||
| 0ffaec6bc2 | |||
| ef8ea9eecf | |||
| b09d22e479 | |||
| 01787b6229 | |||
| 4c022698d3 | |||
| bfdcab0e84 | |||
| aaea5cc963 | |||
| 23d9c33406 | |||
| fad1b216df | |||
| e221b508d3 | |||
| dfafac7d99 | |||
| 2246f8cee2 | |||
| b65fc594dc | |||
| f52b731615 | |||
| 8661ff88c0 | |||
| 99c06c516f | |||
| 0bba7fa373 | |||
| 0c1822b118 | |||
| 10e3d2122f | |||
| 6df8f84421 | |||
| 7fa80ec048 | |||
| b3f9b81984 | |||
| 1393c489c1 | |||
| 640c2cbed0 | |||
| e55509f549 | |||
| 27c7fb0c12 | |||
| 88f3815585 | |||
| 2e9405cfdb | |||
| 9c1b543ed6 | |||
| d34cb0f9c1 | |||
| 116dc90d21 | |||
| 17b9853bb6 | |||
| 8bfb8abd20 | |||
| 9ee3f1f26e | |||
| 5dcff29d8d | |||
| 6cfbd0c8bf | |||
| 01d96cce16 | |||
| 58c376f011 | |||
| 439d339330 | |||
| 44eacc2a47 | |||
| 8135d61398 | |||
| 66208f8265 | |||
| f52251e23a | |||
| dbea93efe5 | |||
| 3bf0740bd1 | |||
| fa7f1b11f3 | |||
| ff914bbdf4 | |||
| b822078d4b | |||
| 290d2ceb50 | |||
| 8ec9025990 | |||
| c4cf856dcd | |||
| 38bb4e25d3 | |||
| 0de996d91c | |||
| 1f38c9b27d | |||
| 234f31b02d | |||
| 00e40e8cd6 | |||
| 0bc6a43dc1 | |||
| e7e0157fbc | |||
| 4cae1a41a5 | |||
| 4fa61e7f52 | |||
| f02ac796f5 | |||
| 22146a6bdc | |||
| 5285eae01d | |||
| c47ca369e4 | |||
| f0b1f62bb1 | |||
| f7aa6d006e | |||
| 6b67cd549f | |||
| fc6bf85822 | |||
| fbd9345cf8 | |||
| 63137b4c4d | |||
| e28dc7a3a6 | |||
| 6e14acc685 | |||
| ba64153f1d | |||
| 72c04e7556 | |||
| 54f37ee5b2 | |||
| 4fbb325313 | |||
| e1d3b95f73 | |||
| 8f7b4b8257 | |||
| 9d906025ea | |||
| d7f4dd65e8 | |||
| 599b119e62 | |||
| 41176464db | |||
| dd0ad19fb9 | |||
| 430625d2fb | |||
| 796cd1a776 | |||
| baa26af0c0 | |||
| ea0c27936e | |||
| 4aade35d19 | |||
| 251a5701af | |||
| 2da3116111 | |||
| 4c82fa1a4a | |||
| 7eef6eece2 | |||
| 570f32e980 | |||
| 16a0351125 | |||
| 2fa9005806 | |||
| 25527997fa | |||
| 4655d8369d | |||
| aeaaace3a4 | |||
| e6997004ff | |||
| 5e1896b7f2 | |||
| 88ca90c13a | |||
| f8ee340499 | |||
| 93f5260e20 | |||
| 34ba44ffa4 | |||
| b3a3e459a4 | |||
| f234564952 | |||
| ffa5795cc9 | |||
| 4f50c51356 | |||
| 9e9c8a0bec | |||
| 1349358d7c | |||
| 9c50f15be7 | |||
| 31e771daca | |||
| 66ce156dea | |||
| db6756bc78 | |||
| cab2581476 | |||
| 4c0be35020 | |||
| 7114201c08 | |||
| d8aecd325b | |||
| 1d18c13817 | |||
| f65eb0cd53 | |||
| 206c3884e9 | |||
| 35f9173980 | |||
| 48ab77eadc | |||
| f486513105 | |||
| f338adf033 | |||
| 74be667114 | |||
| b5a1fc92dc | |||
| 9cec1a8c49 | |||
| d4afba929b | |||
| 70939cbac6 | |||
| a3aa61df6d | |||
| e13ab5cb40 | |||
| d059947925 | |||
| d6c4b730de | |||
| 8241863170 | |||
| 31a758e4f3 | |||
| ca971a0e77 | |||
| a45a0f9a8a | |||
| c2dce52a5b | |||
| a2c63c59c5 | |||
| 7e54a2ce3d | |||
| 5b7fb2c818 | |||
| da0ac281e2 | |||
| 576b37f64c | |||
| 26c2db5023 | |||
| f344dbf35c | |||
| a04acbd4a5 | |||
| bd48aba8d3 | |||
| 12b73bb248 | |||
| c3ff897ef4 | |||
| 242728fbe7 | |||
| 14df7c8d43 |
+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
|
||||
|
||||
|
||||
+8
-1
@@ -5,6 +5,7 @@ plugins {
|
||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-parcelize'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
ext {
|
||||
@@ -38,7 +39,7 @@ protobuf {
|
||||
|
||||
android {
|
||||
namespace 'com.futo.platformplayer'
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
flavorDimensions "buildType"
|
||||
productFlavors {
|
||||
stable {
|
||||
@@ -194,6 +195,12 @@ dependencies {
|
||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
||||
|
||||
//Database
|
||||
implementation("androidx.room:room-runtime:2.6.0")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.0")
|
||||
kapt("androidx.room:room-compiler:2.6.0")
|
||||
implementation("androidx.room:room-ktx:2.6.0")
|
||||
|
||||
//Payment
|
||||
implementation 'com.stripe:stripe-android:20.28.3'
|
||||
|
||||
|
||||
+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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.stores.db.ManagedDBDescriptor
|
||||
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||
import com.futo.platformplayer.testing.DBTOs
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class ManagedDBStoreTests {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext;
|
||||
|
||||
@Test
|
||||
fun startup() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insert() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObj = DBTOs.TestObject();
|
||||
createAndAssert(store, testObj);
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
@Test
|
||||
fun update() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObj = DBTOs.TestObject();
|
||||
val obj = createAndAssert(store, testObj);
|
||||
|
||||
testObj.someStr = "Testing";
|
||||
store.update(obj.id!!, testObj);
|
||||
val obj2 = store.get(obj.id!!);
|
||||
assertIndexEquals(obj2, testObj);
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
@Test
|
||||
fun delete() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObj = DBTOs.TestObject();
|
||||
val obj = createAndAssert(store, testObj);
|
||||
store.delete(obj.id!!);
|
||||
|
||||
Assert.assertEquals(store.count(), 0);
|
||||
Assert.assertNull(store.getOrNull(obj.id!!));
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withIndex() {
|
||||
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.withIndex({it.someString}, index, true)
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObj1 = DBTOs.TestObject();
|
||||
val testObj2 = DBTOs.TestObject();
|
||||
val testObj3 = DBTOs.TestObject();
|
||||
val obj1 = createAndAssert(store, testObj1);
|
||||
val obj2 = createAndAssert(store, testObj2);
|
||||
val obj3 = createAndAssert(store, testObj3);
|
||||
Assert.assertEquals(store.count(), 3);
|
||||
|
||||
Assert.assertTrue(index.containsKey(testObj1.someStr));
|
||||
Assert.assertTrue(index.containsKey(testObj2.someStr));
|
||||
Assert.assertTrue(index.containsKey(testObj3.someStr));
|
||||
Assert.assertEquals(index.size, 3);
|
||||
|
||||
val oldStr = testObj1.someStr;
|
||||
testObj1.someStr = UUID.randomUUID().toString();
|
||||
store.update(obj1.id!!, testObj1);
|
||||
|
||||
Assert.assertEquals(index.size, 3);
|
||||
Assert.assertFalse(index.containsKey(oldStr));
|
||||
Assert.assertTrue(index.containsKey(testObj1.someStr));
|
||||
Assert.assertTrue(index.containsKey(testObj2.someStr));
|
||||
Assert.assertTrue(index.containsKey(testObj3.someStr));
|
||||
|
||||
store.delete(obj2.id!!);
|
||||
Assert.assertEquals(index.size, 2);
|
||||
|
||||
Assert.assertFalse(index.containsKey(oldStr));
|
||||
Assert.assertTrue(index.containsKey(testObj1.someStr));
|
||||
Assert.assertFalse(index.containsKey(testObj2.someStr));
|
||||
Assert.assertTrue(index.containsKey(testObj3.someStr));
|
||||
store.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withUnique() {
|
||||
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.withIndex({it.someString}, index, false, true)
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObj1 = DBTOs.TestObject();
|
||||
val testObj2 = DBTOs.TestObject();
|
||||
val testObj3 = DBTOs.TestObject();
|
||||
val obj1 = createAndAssert(store, testObj1);
|
||||
val obj2 = createAndAssert(store, testObj2);
|
||||
|
||||
testObj3.someStr = testObj2.someStr;
|
||||
Assert.assertEquals(store.insert(testObj3), obj2.id!!);
|
||||
Assert.assertEquals(store.count(), 2);
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
@Test
|
||||
fun getPage() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testObjs = createSequence(store, 25);
|
||||
|
||||
val page1 = store.getPage(0, 10);
|
||||
val page2 = store.getPage(1, 10);
|
||||
val page3 = store.getPage(2, 10);
|
||||
Assert.assertEquals(10, page1.size);
|
||||
Assert.assertEquals(10, page2.size);
|
||||
Assert.assertEquals(5, page3.size);
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun query() {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testStr = UUID.randomUUID().toString();
|
||||
|
||||
val testObj1 = DBTOs.TestObject();
|
||||
val testObj2 = DBTOs.TestObject();
|
||||
val testObj3 = DBTOs.TestObject();
|
||||
val testObj4 = DBTOs.TestObject();
|
||||
testObj3.someStr = testStr;
|
||||
testObj4.someStr = testStr;
|
||||
val obj1 = createAndAssert(store, testObj1);
|
||||
val obj2 = createAndAssert(store, testObj2);
|
||||
val obj3 = createAndAssert(store, testObj3);
|
||||
val obj4 = createAndAssert(store, testObj4);
|
||||
|
||||
val results = store.query(DBTOs.TestIndex::someString, testStr);
|
||||
|
||||
Assert.assertEquals(2, results.size);
|
||||
for(result in results) {
|
||||
if(result.someNum == obj3.someNum)
|
||||
assertIndexEquals(obj3, result);
|
||||
else
|
||||
assertIndexEquals(obj4, result);
|
||||
}
|
||||
store.shutdown();
|
||||
}
|
||||
@Test
|
||||
fun queryPage() {
|
||||
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.withIndex({ it.someNum }, index)
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
|
||||
val testStr = UUID.randomUUID().toString();
|
||||
|
||||
val testResults = createSequence(store, 40, { i, testObject ->
|
||||
if(i % 2 == 0)
|
||||
testObject.someStr = testStr;
|
||||
});
|
||||
val page1 = store.queryPage(DBTOs.TestIndex::someString, testStr, 0,10);
|
||||
val page2 = store.queryPage(DBTOs.TestIndex::someString, testStr, 1,10);
|
||||
val page3 = store.queryPage(DBTOs.TestIndex::someString, testStr, 2,10);
|
||||
|
||||
Assert.assertEquals(10, page1.size);
|
||||
Assert.assertEquals(10, page2.size);
|
||||
Assert.assertEquals(0, page3.size);
|
||||
|
||||
|
||||
store.shutdown();
|
||||
}
|
||||
@Test
|
||||
fun queryPager() {
|
||||
val testStr = UUID.randomUUID().toString();
|
||||
testQuery(100, { i, testObject ->
|
||||
if(i % 2 == 0)
|
||||
testObject.someStr = testStr;
|
||||
}) {
|
||||
val pager = it.queryPager(DBTOs.TestIndex::someString, testStr, 10);
|
||||
|
||||
val items = pager.getResults().toMutableList();
|
||||
while(pager.hasMorePages()) {
|
||||
pager.nextPage();
|
||||
items.addAll(pager.getResults());
|
||||
}
|
||||
Assert.assertEquals(50, items.size);
|
||||
for(i in 0 until 50) {
|
||||
val k = i * 2;
|
||||
Assert.assertEquals(k, items[i].someNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
fun queryLike() {
|
||||
val testStr = UUID.randomUUID().toString();
|
||||
val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length);
|
||||
testQuery(100, { i, testObject ->
|
||||
if(i % 2 == 0)
|
||||
testObject.someStr = testStrLike;
|
||||
}) {
|
||||
val results = it.queryLike(DBTOs.TestIndex::someString, "%Testing%");
|
||||
|
||||
Assert.assertEquals(50, results.size);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
fun queryLikePager() {
|
||||
val testStr = UUID.randomUUID().toString();
|
||||
val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length);
|
||||
testQuery(100, { i, testObject ->
|
||||
if(i % 2 == 0)
|
||||
testObject.someStr = testStrLike;
|
||||
|
||||
}) {
|
||||
val pager = it.queryLikePager(DBTOs.TestIndex::someString, "%Testing%", 10);
|
||||
val items = pager.getResults().toMutableList();
|
||||
while(pager.hasMorePages()) {
|
||||
pager.nextPage();
|
||||
items.addAll(pager.getResults());
|
||||
}
|
||||
Assert.assertEquals(50, items.size);
|
||||
for(i in 0 until 50) {
|
||||
val k = i * 2;
|
||||
Assert.assertEquals(k, items[i].someNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queryGreater() {
|
||||
testQuery(100, { i, testObject ->
|
||||
testObject.someNum = i;
|
||||
}) {
|
||||
val results = it.queryGreater(DBTOs.TestIndex::someNum, 51);
|
||||
Assert.assertEquals(48, results.size);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
fun querySmaller() {
|
||||
testQuery(100, { i, testObject ->
|
||||
testObject.someNum = i;
|
||||
}) {
|
||||
val results = it.querySmaller(DBTOs.TestIndex::someNum, 30);
|
||||
Assert.assertEquals(30, results.size);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
fun queryBetween() {
|
||||
testQuery(100, { i, testObject ->
|
||||
testObject.someNum = i;
|
||||
}) {
|
||||
val results = it.queryBetween(DBTOs.TestIndex::someNum, 30, 65);
|
||||
Assert.assertEquals(34, results.size);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
fun queryIn() {
|
||||
val ids = mutableListOf<String>()
|
||||
testQuery(1100, { i, testObject ->
|
||||
testObject.someNum = i;
|
||||
ids.add(testObject.someStr);
|
||||
}) {
|
||||
val pager = it.queryInPager(DBTOs.TestIndex::someString, ids.take(1000), 65);
|
||||
val list = mutableListOf<Any>();
|
||||
list.addAll(pager.getResults());
|
||||
while(pager.hasMorePages())
|
||||
{
|
||||
pager.nextPage();
|
||||
list.addAll(pager.getResults());
|
||||
}
|
||||
Assert.assertEquals(1000, list.size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun testQuery(items: Int, modifier: (Int, DBTOs.TestObject)->Unit, testing: (ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>)->Unit) {
|
||||
val store = ManagedDBStore.create("test", Descriptor())
|
||||
.load(context, true);
|
||||
store.deleteAll();
|
||||
createSequence(store, items, modifier);
|
||||
try {
|
||||
testing(store);
|
||||
}
|
||||
finally {
|
||||
store.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun createSequence(store: ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>, count: Int, modifier: ((Int, DBTOs.TestObject)->Unit)? = null): List<DBTOs.TestIndex> {
|
||||
val list = mutableListOf<DBTOs.TestIndex>();
|
||||
for(i in 0 until count) {
|
||||
val obj = DBTOs.TestObject();
|
||||
obj.someNum = i;
|
||||
modifier?.invoke(i, obj);
|
||||
list.add(createAndAssert(store, obj));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private fun createAndAssert(store: ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>, obj: DBTOs.TestObject): DBTOs.TestIndex {
|
||||
val id = store.insert(obj);
|
||||
Assert.assertTrue(id > 0);
|
||||
|
||||
val dbObj = store.get(id);
|
||||
assertIndexEquals(dbObj, obj);
|
||||
return dbObj;
|
||||
}
|
||||
|
||||
private fun assertObjectEquals(obj1: DBTOs.TestObject, obj2: DBTOs.TestObject) {
|
||||
Assert.assertEquals(obj1.someStr, obj2.someStr);
|
||||
Assert.assertEquals(obj1.someNum, obj2.someNum);
|
||||
}
|
||||
private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestObject) {
|
||||
Assert.assertEquals(obj1.someString, obj2.someStr);
|
||||
Assert.assertEquals(obj1.someNum, obj2.someNum);
|
||||
assertObjectEquals(obj1.obj, obj2);
|
||||
}
|
||||
private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestIndex) {
|
||||
Assert.assertEquals(obj1.someString, obj2.someString);
|
||||
Assert.assertEquals(obj1.someNum, obj2.someNum);
|
||||
assertIndexEquals(obj1, obj2.obj);
|
||||
}
|
||||
|
||||
|
||||
class Descriptor: ManagedDBDescriptor<DBTOs.TestObject, DBTOs.TestIndex, DBTOs.DB, DBTOs.DBDAO>() {
|
||||
override val table_name: String = "testing";
|
||||
override fun indexClass(): KClass<DBTOs.TestIndex> = DBTOs.TestIndex::class;
|
||||
override fun dbClass(): KClass<DBTOs.DB> = DBTOs.DB::class;
|
||||
override fun create(obj: DBTOs.TestObject): DBTOs.TestIndex = DBTOs.TestIndex(obj);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -38,6 +40,8 @@
|
||||
android:enabled="true" />
|
||||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
<receiver android:name=".receivers.PlannedNotificationReceiver" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
@@ -57,6 +61,14 @@
|
||||
|
||||
<data android:scheme="grayjay" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="fcast" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -91,6 +103,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 +214,12 @@
|
||||
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" />
|
||||
<activity
|
||||
android:name=".activities.FCastGuideActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
|
||||
@@ -217,6 +217,9 @@ function pluginUpdateTestPlugin(config) {
|
||||
}
|
||||
function pluginLoginTestPlugin() {
|
||||
return syncGET("/plugin/loginTestPlugin", {});
|
||||
}//captchaLoginTestPlugin
|
||||
function pluginCaptchaTestPlugin(url, html) {
|
||||
return syncPOST("/plugin/captchaTestPlugin?url=" + url, {}, html);
|
||||
}
|
||||
function pluginLogoutTestPlugin() {
|
||||
return syncGET("/plugin/logoutTestPlugin", {});
|
||||
|
||||
@@ -540,6 +540,8 @@
|
||||
<script>
|
||||
IS_TESTING = true;
|
||||
let lastScriptTag = null;
|
||||
let shouldDevLog = true;
|
||||
let shouldLoginCheck = true;
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
@@ -603,7 +605,7 @@
|
||||
};
|
||||
setInterval(()=>{
|
||||
try{
|
||||
if(!this.Plugin.currentPlugin)
|
||||
if(!this.Plugin.currentPlugin || !shouldDevLog)
|
||||
return;
|
||||
|
||||
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
|
||||
@@ -638,7 +640,8 @@
|
||||
}, 1000);
|
||||
setInterval(()=>{
|
||||
try{
|
||||
this.isTestLoggedIn();
|
||||
if(shouldLoginCheck)
|
||||
this.isTestLoggedIn();
|
||||
}catch(ex){}
|
||||
}, 2500);
|
||||
},
|
||||
@@ -681,6 +684,9 @@
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
captchaTestPlugin() {
|
||||
captchaLoginTestPlugin();
|
||||
},
|
||||
logoutTestPlugin() {
|
||||
pluginLogoutTestPlugin();
|
||||
},
|
||||
@@ -838,6 +844,12 @@
|
||||
this.Testing.lastResultError = "";
|
||||
}
|
||||
catch(ex) {
|
||||
if(ex.plugin_type == "CaptchaRequiredException") {
|
||||
let shouldCaptcha = confirm("Do you want to request captcha?");
|
||||
if(shouldCaptcha) {
|
||||
pluginCaptchaTestPlugin(ex.url, ex.body);
|
||||
}
|
||||
}
|
||||
console.error("Failed to run test for " + req.title, ex);
|
||||
this.Testing.lastResult = ""
|
||||
if(ex.message)
|
||||
|
||||
@@ -10,7 +10,8 @@ let Type = {
|
||||
Videos: "VIDEOS",
|
||||
Streams: "STREAMS",
|
||||
Mixed: "MIXED",
|
||||
Live: "LIVE"
|
||||
Live: "LIVE",
|
||||
Subscriptions: "SUBSCRIPTIONS"
|
||||
},
|
||||
Order: {
|
||||
Chronological: "CHRONOLOGICAL"
|
||||
@@ -31,6 +32,12 @@ let Type = {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
||||
SKIPPABLE: 5,
|
||||
SKIP: 6
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +79,11 @@ class CaptchaRequiredException extends Error {
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
class CriticalException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("CriticalException", msg);
|
||||
}
|
||||
}
|
||||
class UnavailableException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("UnavailableException", msg);
|
||||
@@ -148,13 +160,27 @@ class FilterCapability {
|
||||
|
||||
|
||||
class PlatformAuthorLink {
|
||||
constructor(id, name, url, thumbnail, subscribers) {
|
||||
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
|
||||
this.id = id ?? PlatformID(); //PlatformID
|
||||
this.name = name ?? ""; //string
|
||||
this.url = url ?? ""; //string
|
||||
this.thumbnail = thumbnail; //string
|
||||
if(subscribers)
|
||||
this.subscribers = subscribers;
|
||||
if(membershipUrl)
|
||||
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
|
||||
}
|
||||
}
|
||||
class PlatformAuthorMembershipLink {
|
||||
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
|
||||
this.id = id ?? PlatformID(); //PlatformID
|
||||
this.name = name ?? ""; //string
|
||||
this.url = url ?? ""; //string
|
||||
this.thumbnail = thumbnail; //string
|
||||
if(subscribers)
|
||||
this.subscribers = subscribers;
|
||||
if(membershipUrl)
|
||||
this.membershipUrl = membershipUrl ?? null; //string
|
||||
}
|
||||
}
|
||||
class PlatformContent {
|
||||
@@ -185,6 +211,16 @@ class PlatformNestedMediaContent extends PlatformContent {
|
||||
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
|
||||
}
|
||||
}
|
||||
class PlatformLockedContent extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 70);
|
||||
obj = obj ?? {};
|
||||
this.contentName = obj.contentName;
|
||||
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
|
||||
this.unlockUrl = obj.unlockUrl ?? "";
|
||||
this.lockDescription = obj.lockDescription;
|
||||
}
|
||||
}
|
||||
class PlatformVideo extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 1);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.google.common.base.CharMatcher
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
private const val IPV4_PART_COUNT = 4;
|
||||
@@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
|
||||
return connectedSocket;
|
||||
}
|
||||
|
||||
fun InputStream.readHttpHeaderBytes() : ByteArray {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 4) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
throw IOException("Unexpected end of stream while reading headers")
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
}
|
||||
|
||||
headerBytes.write(b)
|
||||
}
|
||||
|
||||
return headerBytes.toByteArray()
|
||||
}
|
||||
|
||||
fun InputStream.readLine() : String? {
|
||||
val line = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 2) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
line.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
return String(line.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.adapters.CommentViewHolder
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import userpackage.Protocol
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
@@ -35,4 +40,25 @@ 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) })
|
||||
}
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val exceptions = fullyBackfillServers()
|
||||
for (pair in exceptions) {
|
||||
val server = pair.key
|
||||
val exception = pair.value
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement(
|
||||
"backfill-failed",
|
||||
"Backfill failed",
|
||||
"Failed to backfill server $server. $exception",
|
||||
AnnouncementType.SESSION_RECURRING
|
||||
);
|
||||
|
||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
if(this is T)
|
||||
@@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
||||
if(result != null)
|
||||
return cb(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
fun String?.yesNoToBoolean(): Boolean {
|
||||
return this?.uppercase() == "YES"
|
||||
}
|
||||
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
||||
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
|
||||
else
|
||||
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
|
||||
};
|
||||
Float::class -> {
|
||||
if(this is V8ValueDouble)
|
||||
return this.value.toFloat() as T;
|
||||
else if(this is V8ValueInteger)
|
||||
return this.value.toFloat() as T;
|
||||
else if(this is V8ValueLong)
|
||||
return this.value.toFloat() as T;
|
||||
else
|
||||
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
|
||||
};
|
||||
Double::class -> {
|
||||
if(this is V8ValueDouble)
|
||||
return this.value.toDouble() as T;
|
||||
else if(this is V8ValueInteger)
|
||||
return this.value.toDouble() as T;
|
||||
else if(this is V8ValueLong)
|
||||
return this.value.toDouble() as T;
|
||||
else
|
||||
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
|
||||
};
|
||||
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
|
||||
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
|
||||
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
|
||||
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
|
||||
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
|
||||
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
|
||||
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
|
||||
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
outRect.left = betweenSpace
|
||||
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == 0) {
|
||||
outRect.left = startSpace
|
||||
}
|
||||
|
||||
else if (position == state.itemCount - 1) {
|
||||
outRect.right = endSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,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,9 @@ import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -42,24 +44,49 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(
|
||||
"Manage Polycentric identity", FieldForm.BUTTON,
|
||||
"Manage your Polycentric identity", -2
|
||||
)
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
if (StatePolycentric.instance.processHandle != null) {
|
||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.processHandle != null) {
|
||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||
} else {
|
||||
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
|
||||
}
|
||||
} else {
|
||||
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
|
||||
UIDialogs.toast(it, "Polycentric is disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
|
||||
@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, -4)
|
||||
@FormFieldButton(R.drawable.ic_data_alert)
|
||||
fun openIssues() {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@FormField(
|
||||
"Submit feedback", FieldForm.BUTTON,
|
||||
"Give feedback on the application", -1
|
||||
R.string.submit_feedback, FieldForm.BUTTON,
|
||||
R.string.give_feedback_on_the_application, -1
|
||||
)
|
||||
@FormFieldButton(R.drawable.ic_bug)
|
||||
fun submitFeedback() {
|
||||
try {
|
||||
val i = Intent(Intent.ACTION_VIEW);
|
||||
@@ -73,12 +100,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
"Manage Tabs", FieldForm.BUTTON,
|
||||
"Change tabs visible on the home screen", -1
|
||||
)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -89,11 +114,58 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Home", "group", "Configure how your Home tab works and feels", 1)
|
||||
|
||||
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -2)
|
||||
@FormFieldButton(R.drawable.ic_move_up)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
val intent = MainActivity.getImportOptionsIntent(act);
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
|
||||
act.startActivity(intent);
|
||||
}
|
||||
|
||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
|
||||
@FormFieldButton(R.drawable.ic_link)
|
||||
fun manageLinks() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show url handling prompt", e)
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
class LanguageSettings {
|
||||
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
|
||||
@DropdownFieldOptionsId(R.array.app_languages)
|
||||
var appLanguage: Int = 0;
|
||||
|
||||
fun getAppLanguageLocaleString(): String? {
|
||||
return when(appLanguage) {
|
||||
0 -> null
|
||||
1 -> "en";
|
||||
2 -> "de";
|
||||
3 -> "es";
|
||||
4 -> "pt";
|
||||
5 -> "fr"
|
||||
6 -> "ja";
|
||||
7 -> "ko";
|
||||
8 -> "zh";
|
||||
9 -> "ru";
|
||||
10 -> "ar";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
|
||||
var home = HomeSettings();
|
||||
@Serializable
|
||||
class HomeSettings {
|
||||
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
@@ -103,21 +175,45 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
|
||||
@FormFieldButton(R.drawable.ic_visibility_off)
|
||||
fun clearHidden() {
|
||||
StateMeta.instance.removeAllHiddenCreators();
|
||||
StateMeta.instance.removeAllHiddenVideos();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Search", "group", "", 2)
|
||||
@FormField(R.string.search, "group", -1, 2)
|
||||
var search = SearchSettings();
|
||||
@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;
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
|
||||
fun getSearchFeedStyle(): FeedStyle {
|
||||
if(searchFeedStyle == 0)
|
||||
@@ -127,11 +223,21 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3)
|
||||
|
||||
@FormField(R.string.channel, "group", -1, 3)
|
||||
var channel = ChannelSettings();
|
||||
@Serializable
|
||||
class ChannelSettings {
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
|
||||
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 +248,20 @@ 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.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
|
||||
@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, 8)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
|
||||
@DropdownFieldOptionsId(R.array.background_interval)
|
||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||
|
||||
@@ -162,26 +277,43 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
|
||||
|
||||
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var subscriptionConcurrency: Int = 3;
|
||||
|
||||
fun getSubscriptionsConcurrency() : Int {
|
||||
return threadIndexToCount(subscriptionConcurrency);
|
||||
}
|
||||
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
StateCache.instance.clear();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Player", "group", "Change behavior of the player", 4)
|
||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
|
||||
var playback = PlaybackSettings();
|
||||
@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 +329,29 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2)
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 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, R.string.preferred_metered_quality_description, 3)
|
||||
@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, R.string.preferred_preview_quality_description, 4)
|
||||
@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, 5)
|
||||
@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, 6)
|
||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
@@ -227,21 +359,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return autoRotateDeadZone * 5;
|
||||
}
|
||||
|
||||
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6)
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||
@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 +377,56 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
|
||||
@DropdownFieldOptionsId(R.array.chapter_fps)
|
||||
var chapterUpdateFPS: Int = 0;
|
||||
|
||||
fun getChapterUpdateFrames(): Int {
|
||||
return when(chapterUpdateFPS) {
|
||||
0 -> 24
|
||||
1 -> 30
|
||||
2 -> 60
|
||||
3 -> 120
|
||||
else -> 1
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
||||
var backgroundSwitchToAudio: Boolean = true;
|
||||
|
||||
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
|
||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||
var restartPlaybackAfterLoss: Int = 1;
|
||||
|
||||
@FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12)
|
||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||
var restartPlaybackAfterConnectivityLoss: Int = 1;
|
||||
}
|
||||
|
||||
@FormField("Downloads", "group", "Configure downloading of videos", 5)
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
var comments = CommentSettings();
|
||||
@Serializable
|
||||
class CommentSettings {
|
||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||
var defaultCommentSection: Int = 0;
|
||||
|
||||
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
||||
var badReputationCommentsFading: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||
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 +439,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,23 +461,26 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Browsing", "group", "Configure browsing behavior", 6)
|
||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
|
||||
var browsing = Browsing();
|
||||
@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, 9)
|
||||
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;
|
||||
|
||||
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var keepScreenOn: Boolean = true;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@@ -325,25 +498,21 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}*/
|
||||
}
|
||||
|
||||
|
||||
@FormField("Logging", FieldForm.GROUP, "", 8)
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
|
||||
var logging = Logging();
|
||||
@Serializable
|
||||
class Logging {
|
||||
@FormField("Log Level", FieldForm.DROPDOWN, "", 0)
|
||||
@FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
@FormField(
|
||||
"Submit logs", FieldForm.BUTTON,
|
||||
"Submit logs to help us narrow down issues", 1
|
||||
)
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Please enable logging to submit logs") }
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -353,43 +522,40 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField("Announcement", FieldForm.GROUP, "", 10)
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
|
||||
var announcementSettings = AnnouncementSettings();
|
||||
@Serializable
|
||||
class AnnouncementSettings {
|
||||
@FormField(
|
||||
"Reset announcements", FieldForm.BUTTON,
|
||||
"Reset hidden announcements", 1
|
||||
)
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun resetAnnouncements() {
|
||||
StateAnnouncement.instance.resetAnnouncements();
|
||||
UIDialogs.toast("Announcements reset.");
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Plugins", FieldForm.GROUP, "", 11)
|
||||
@FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
|
||||
var notifications = NotificationSettings();
|
||||
@Serializable
|
||||
class NotificationSettings {
|
||||
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
|
||||
var plannedContentNotification: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||
@Transient
|
||||
var plugins = Plugins();
|
||||
@Serializable
|
||||
class Plugins {
|
||||
|
||||
@FormField("Clear Cookies on Logout", FieldForm.TOGGLE, "Clears cookies when you log out, allowing you to change account.", 0)
|
||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
@FormField(
|
||||
"Clear Cookies", FieldForm.BUTTON,
|
||||
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1
|
||||
)
|
||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(
|
||||
"Reinstall Embedded Plugins", FieldForm.BUTTON,
|
||||
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1
|
||||
)
|
||||
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||
fun reinstallEmbedded() {
|
||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -397,7 +563,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 +578,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField("External Storage", FieldForm.GROUP, "", 12)
|
||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
|
||||
var storage = Storage();
|
||||
@Serializable
|
||||
class Storage {
|
||||
@@ -424,34 +590,41 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
|
||||
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
|
||||
|
||||
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
|
||||
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
||||
fun changeStorageGeneral() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||
}
|
||||
}
|
||||
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
|
||||
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
||||
fun changeStorageDownload() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
|
||||
fun clearStorageDownload() {
|
||||
Settings.instance.storage.storage_download = null;
|
||||
Settings.instance.save();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField("Auto Update", "group", "Configure the auto updater", 12)
|
||||
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
|
||||
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;
|
||||
|
||||
@@ -468,10 +641,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Manual check", FieldForm.BUTTON,
|
||||
"Manually check for updates", 3
|
||||
)
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -482,19 +652,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
try {
|
||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
UIDialogs.toast(it, "Failed to show store.");
|
||||
UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"View changelog", FieldForm.BUTTON,
|
||||
"Review the current and past changelogs", 4
|
||||
)
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
UIDialogs.toast("Retrieving changelog");
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
|
||||
@@ -510,10 +678,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Remove Cached Version", FieldForm.BUTTON,
|
||||
"Remove the last downloaded version", 5
|
||||
)
|
||||
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
|
||||
fun removeCachedVersion() {
|
||||
StateApp.withContext {
|
||||
val outputDirectory = File(it.filesDir, "autoupdate");
|
||||
@@ -529,7 +694,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Backup", FieldForm.GROUP, "", 13)
|
||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 16)
|
||||
var backup = Backup();
|
||||
@Serializable
|
||||
class Backup {
|
||||
@@ -539,58 +704,78 @@ 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();
|
||||
val activity = SettingsActivity.getActivity() ?: return;
|
||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
|
||||
StateBackup.shareExternalBackup();
|
||||
}),
|
||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
|
||||
StateBackup.saveExternalBackup(activity);
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Payment", FieldForm.GROUP, "", 14)
|
||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 17)
|
||||
var payment = Payment();
|
||||
@Serializable
|
||||
class Payment {
|
||||
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1)
|
||||
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid";
|
||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
|
||||
@FormField("Clear Payment", FieldForm.BUTTON, "Deletes license keys from app", 2)
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
|
||||
fun clearPayment() {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Licenses cleared, might require app restart");
|
||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||
it.reloadSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Info", FieldForm.GROUP, "", 15)
|
||||
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
|
||||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
||||
var bypassRotationPrevention: Boolean = false;
|
||||
|
||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
|
||||
var polycentricEnabled: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
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 +786,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,23 +2,42 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.DeveloperActivity
|
||||
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.models.video.IPlatformVideo
|
||||
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
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||
import com.futo.platformplayer.views.fields.ButtonField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -27,28 +46,31 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.time.OffsetDateTime
|
||||
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 +79,192 @@ 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.cache, FieldForm.GROUP, -1, 3)
|
||||
val cache: Cache = Cache();
|
||||
@Serializable
|
||||
class Cache {
|
||||
|
||||
@FormField(R.string.subscriptions_cache_5000, FieldForm.BUTTON, -1, 1, "subscription_cache_button")
|
||||
fun subscriptionsCache5000() {
|
||||
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"Started caching 5000 sub items"
|
||||
);
|
||||
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val subsCache =
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(cacheScope = this)?.first;
|
||||
|
||||
var total = 0;
|
||||
var page = 0;
|
||||
var lastToast = System.currentTimeMillis();
|
||||
while(subsCache!!.hasMorePages() && total < 5000) {
|
||||
subsCache!!.nextPage();
|
||||
total += subsCache!!.getResults().size;
|
||||
page++;
|
||||
|
||||
if(page % 10 == 0)
|
||||
withContext(Dispatchers.Main) {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
Thread.sleep(250);
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SettingsDev", ex.message, ex);
|
||||
Logger.i("SettingsDev", "Failed: ${ex.message}");
|
||||
}
|
||||
finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.history_cache_100, FieldForm.BUTTON, -1, 1, "history_cache_button")
|
||||
fun historyCache100() {
|
||||
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"Started caching 100 history items (from home)"
|
||||
);
|
||||
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val subsCache = StatePlatform.instance.getHome();
|
||||
|
||||
var num = 0;
|
||||
for(item in subsCache.getResults().filterIsInstance<IPlatformVideo>()) {
|
||||
StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4))
|
||||
num++;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
var page = 0;
|
||||
var lastToast = System.currentTimeMillis();
|
||||
while(subsCache!!.hasMorePages() && total < 5000) {
|
||||
subsCache!!.nextPage();
|
||||
total += subsCache!!.getResults().size;
|
||||
page++;
|
||||
|
||||
for(item in subsCache.getResults().filterIsInstance<IPlatformVideo>()) {
|
||||
StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4))
|
||||
num++;
|
||||
}
|
||||
|
||||
if(page % 4 == 0)
|
||||
withContext(Dispatchers.Main) {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SettingsDev", ex.message, ex);
|
||||
Logger.i("SettingsDev", "Failed: ${ex.message}");
|
||||
}
|
||||
finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
if(button is ButtonField)
|
||||
button.setButtonEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.crash_me, FieldForm.BUTTON,
|
||||
R.string.crashes_the_application_on_purpose, 3)
|
||||
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, 3)
|
||||
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, 3)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance()
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||
.build();
|
||||
wm.enqueue(req);
|
||||
}
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun clearChannelContentCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||
StateCache.instance.clearToday();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||
}
|
||||
|
||||
|
||||
@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 +306,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 +351,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 +372,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 +438,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;
|
||||
@@ -320,6 +498,17 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
@FormField(R.string.dev_info_channel_cache_size, FieldForm.READONLYTEXT, -1, 1, "channelCacheSize")
|
||||
var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount;
|
||||
}
|
||||
|
||||
//region BOILERPLATE
|
||||
override fun encode(): String {
|
||||
return Json.encodeToString(this);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
@@ -10,12 +13,12 @@ import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.dialogs.*
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
@@ -91,6 +94,50 @@ class UIDialogs {
|
||||
}.toTypedArray());
|
||||
}
|
||||
|
||||
fun showUrlHandlingPrompt(context: Context, onYes: (() -> Unit)? = null) {
|
||||
val builder = AlertDialog.Builder(context)
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_url_handling, null)
|
||||
builder.setView(view)
|
||||
|
||||
val dialog = builder.create()
|
||||
registerDialogOpened(dialog)
|
||||
|
||||
view.findViewById<TextView>(R.id.button_no).apply {
|
||||
this.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<LinearLayout>(R.id.button_yes).apply {
|
||||
this.setOnClickListener {
|
||||
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
dialog.dismiss()
|
||||
showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.play_store_version_does_not_support_default_url_handling)) {
|
||||
onYes?.invoke()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
val intent =
|
||||
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
val uri = Uri.fromParts("package", context.packageName, null)
|
||||
intent.data = uri
|
||||
context.startActivity(intent)
|
||||
} catch (e: Throwable) {
|
||||
toast(context, context.getString(R.string.failed_to_show_settings))
|
||||
}
|
||||
|
||||
onYes?.invoke()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
registerDialogClosed(dialog)
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
||||
val dialogAction: ()->Unit = {
|
||||
@@ -100,14 +147,15 @@ 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));
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
else {
|
||||
dialogAction();
|
||||
}
|
||||
@@ -142,8 +190,10 @@ class UIDialogs {
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if(code == null)
|
||||
this.visibility = View.GONE;
|
||||
else
|
||||
else {
|
||||
this.text = code;
|
||||
this.visibility = View.VISIBLE;
|
||||
}
|
||||
};
|
||||
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
||||
val buttons = actions.map<Action, TextView> { act ->
|
||||
@@ -211,10 +261,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 +276,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)
|
||||
}
|
||||
|
||||
@@ -279,6 +329,12 @@ class UIDialogs {
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
fun showImportOptionsDialog(context: MainActivity) {
|
||||
val dialog = ImportOptionsDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
fun showCastingDialog(context: Context) {
|
||||
@@ -291,11 +347,22 @@ class UIDialogs {
|
||||
} else {
|
||||
val dialog = ConnectCastingDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
val c = context
|
||||
if (c is Activity) {
|
||||
dialog.setOwnerActivity(c);
|
||||
}
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastingTutorialDialog(context: Context) {
|
||||
val dialog = CastingHelpDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showCastingAddDialog(context: Context) {
|
||||
val dialog = CastingAddDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
|
||||
@@ -4,10 +4,14 @@ import android.content.ContentResolver
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||
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
|
||||
@@ -16,8 +20,10 @@ 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.parsers.HLS
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
@@ -45,6 +51,175 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
val originalLive = subscription.doFetchLive;
|
||||
val originalStream = subscription.doFetchStreams;
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
}, false) else null).filterNotNull());
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
|
||||
|
||||
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
|
||||
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
|
||||
//TODO: Implement subtitles
|
||||
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
|
||||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
if (videoButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons))
|
||||
}
|
||||
if (audioButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons))
|
||||
}
|
||||
//TODO: Implement subtitles
|
||||
/*if (subtitleButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons))
|
||||
}*/
|
||||
|
||||
slideUpMenuOverlay.onOK.subscribe {
|
||||
//TODO: Fix SubtitleRawSource issue
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
||||
slideUpMenuOverlay.hide()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
slideUpMenuOverlay.setItems(newItems)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (source is IHLSManifestSource) {
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
|
||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slideUpMenuOverlay.apply { show() }
|
||||
|
||||
}
|
||||
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
@@ -64,50 +239,69 @@ 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() }
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it as IVideoUrlSource;
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk("Download");
|
||||
}, false)
|
||||
if (it is IVideoUrlSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
} else if (it is IHLSManifestSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
} else {
|
||||
throw Exception("Unhandled source type")
|
||||
}
|
||||
}).flatten().toList()
|
||||
));
|
||||
|
||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
|
||||
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
|
||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
|
||||
//TODO: Add HLS support here
|
||||
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
|
||||
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||
) as IVideoUrlSource;
|
||||
}
|
||||
|
||||
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");
|
||||
}, false);
|
||||
if (it is IAudioUrlSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false);
|
||||
} else if (it is IHLSManifestAudioSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
} else {
|
||||
throw Exception("Unhandled source type")
|
||||
}
|
||||
}));
|
||||
val asources = audioSources;
|
||||
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
||||
@@ -116,16 +310,16 @@ class UISlideOverlays {
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
||||
menu?.selectOption(asources, preferredAudioSource);
|
||||
|
||||
|
||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
|
||||
//TODO: Add HLS support here
|
||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
|
||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||
}
|
||||
|
||||
//ContentResolver is required for subtitles..
|
||||
if(contentResolver != null) {
|
||||
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
|
||||
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||
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 +333,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 +342,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 +379,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 +389,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 +403,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 +414,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 +426,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 +434,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 +471,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 {
|
||||
@@ -296,7 +490,7 @@ class UISlideOverlays {
|
||||
val dp70 = 70.dp(container.context.resources);
|
||||
val dp15 = 15.dp(container.context.resources);
|
||||
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
|
||||
Loader(container.context, true, dp70).apply {
|
||||
LoaderView(container.context, true, dp70).apply {
|
||||
this.setPadding(0, dp15, 0, dp15);
|
||||
}
|
||||
), true);
|
||||
@@ -304,14 +498,41 @@ class UISlideOverlays {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
|
||||
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
|
||||
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
addPlaylistOverlay.onOK.subscribe {
|
||||
val text = nameInput.text;
|
||||
if (text.isBlank()) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
addPlaylistOverlay.hide();
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
onCreate(text)
|
||||
};
|
||||
|
||||
addPlaylistOverlay.onCancel.subscribe {
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
};
|
||||
|
||||
addPlaylistOverlay.show();
|
||||
nameInput.activate();
|
||||
|
||||
return addPlaylistOverlay
|
||||
}
|
||||
|
||||
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
val 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 +543,35 @@ class UISlideOverlays {
|
||||
val allPlaylists = StatePlaylists.instance.getPlaylists();
|
||||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, "Actions", "actions",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false)
|
||||
))
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
}))
|
||||
+ actions)
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, "Add To", "addto",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue",
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
|
||||
{ StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "Add to " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} videos", "watch later",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
|
||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); })
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
};
|
||||
}, false))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
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 +579,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 +593,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 +606,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 +625,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 +645,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 +658,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 +666,7 @@ class UISlideOverlays {
|
||||
|
||||
var overlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
overlay = SlideUpMenuOverlay(container.context, container, title, "Save", true,
|
||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
||||
options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
|
||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||
if(!selection.contains(it.second))
|
||||
|
||||
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
|
||||
}
|
||||
}
|
||||
|
||||
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
|
||||
fun String.isHttpUrl(): Boolean {
|
||||
return _regexUrl.matchEntire(this) != null;
|
||||
}
|
||||
|
||||
|
||||
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
|
||||
fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
@@ -158,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
|
||||
}
|
||||
|
||||
fun File.share(context: Context) {
|
||||
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
|
||||
|
||||
fun DocumentFile.share(context: Context) {
|
||||
val shareIntent = Intent();
|
||||
shareIntent.action = Intent.ACTION_SEND;
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
private var _config: SourcePluginConfig? = null;
|
||||
private var _script: String? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -96,8 +101,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
var url = intent?.dataString;
|
||||
|
||||
if(url == null)
|
||||
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null,
|
||||
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
||||
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||
else {
|
||||
if(url.startsWith("vfuto://"))
|
||||
url = "https://" + url.substring("vfuto://".length);
|
||||
@@ -129,14 +134,14 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
Logger.e(TAG, "Failed decode config", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
|
||||
"Invalid Config Format", null, null,
|
||||
getString(R.string.invalid_config_format), null, null,
|
||||
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||
};
|
||||
return@launch;
|
||||
} catch(ex: Exception) {
|
||||
Logger.e(TAG, "Failed fetch config", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex);
|
||||
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_configuration), ex);
|
||||
};
|
||||
return@launch;
|
||||
}
|
||||
@@ -152,7 +157,7 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(TAG, "Failed fetch script", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex);
|
||||
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_script), ex);
|
||||
};
|
||||
return@launch;
|
||||
}
|
||||
@@ -175,8 +180,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
_sourcePermissions.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_language,
|
||||
"URL Access",
|
||||
"The plugin will have access to the following domains",
|
||||
getString(R.string.url_access),
|
||||
getString(R.string.the_plugin_will_have_access_to_the_following_domains),
|
||||
config.allowUrls, true)
|
||||
)
|
||||
|
||||
@@ -184,8 +189,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
_sourcePermissions.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_code,
|
||||
"Eval Access",
|
||||
"The plugin will have access to eval capability (remote injection)",
|
||||
getString(R.string.eval_access),
|
||||
getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
|
||||
config.allowUrls, true)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,6 +8,7 @@ import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
@@ -16,13 +18,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 +34,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;
|
||||
}
|
||||
|
||||
@@ -42,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_add_source_options);
|
||||
@@ -51,6 +58,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
_buttonURL = findViewById(R.id.option_url);
|
||||
_buttonPlugins = findViewById(R.id.option_plugins);
|
||||
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
@@ -59,7 +67,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 +77,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() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.CaptchaWebViewClient
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_captcha);
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.IField
|
||||
|
||||
class DeveloperActivity : AppCompatActivity() {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
|
||||
fun getField(id: String): IField? {
|
||||
return _form.findField(id);
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
DeveloperActivity._lastActivity = this;
|
||||
setContentView(R.layout.activity_dev);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
@@ -33,4 +40,19 @@ class DeveloperActivity : AppCompatActivity() {
|
||||
super.finish()
|
||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _lastActivity: DeveloperActivity? = null;
|
||||
|
||||
fun getActivity(): DeveloperActivity? {
|
||||
val act = _lastActivity;
|
||||
if(act != null)
|
||||
return act;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_exception);
|
||||
@@ -38,8 +44,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 +85,13 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
|
||||
private fun submitFile() {
|
||||
if (_submitted) {
|
||||
Toast.makeText(this, "Logs already submitted.", Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(this, getString(R.string.logs_already_submitted), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
val file = _file;
|
||||
if (file == null) {
|
||||
Toast.makeText(this, "No logs found.", Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(this, getString(R.string.no_logs_found), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,14 +107,14 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (id == null) {
|
||||
try {
|
||||
Toast.makeText(this@ExceptionActivity, "Failed automated share, share manually?", Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(this@ExceptionActivity, getString(R.string.failed_automated_share_share_manually), Toast.LENGTH_LONG).show();
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
} else {
|
||||
_submitted = true;
|
||||
file.delete();
|
||||
Toast.makeText(this@ExceptionActivity, "Shared $id", Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(this@ExceptionActivity, getString(R.string.shared_id).replace("{id}", id), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,10 +125,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
val i = Intent(Intent.ACTION_SEND);
|
||||
i.type = "text/plain";
|
||||
i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org"));
|
||||
i.putExtra(Intent.EXTRA_SUBJECT, "Unhandled exception in VS");
|
||||
i.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.unhandled_exception_in_vs));
|
||||
i.putExtra(Intent.EXTRA_TEXT, exceptionString);
|
||||
|
||||
startActivity(Intent.createChooser(i, "Send exception to developers..."));
|
||||
startActivity(Intent.createChooser(i, getString(R.string.send_exception_to_developers)));
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dialogs.CastingHelpDialog
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class FCastGuideActivity : AppCompatActivity() {
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_fcast_guide);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
findViewById<TextView>(R.id.text_explanation).apply {
|
||||
val guideText = """
|
||||
<h3>1. Install FCast Receiver:</h3>
|
||||
<p>- Open Play Store, FireStore, or FCast website on your TV/desktop.<br>
|
||||
- Search for "FCast Receiver", install and open it.</p>
|
||||
<br>
|
||||
|
||||
<h3>2. Prepare the Grayjay App:</h3>
|
||||
<p>- Ensure it's connected to the same network as the FCast Receiver.</p>
|
||||
<br>
|
||||
|
||||
<h3>3. Initiate Casting from Grayjay:</h3>
|
||||
<p>- Click the cast button in Grayjay.</p>
|
||||
<br>
|
||||
|
||||
<h3>4. Connect to FCast Receiver:</h3>
|
||||
<p>- Wait for your device to show in the list or add it manually with its IP address.</p>
|
||||
<br>
|
||||
|
||||
<h3>5. Confirm Connection:</h3>
|
||||
<p>- Click "OK" to confirm your device selection.</p>
|
||||
<br>
|
||||
|
||||
<h3>6. Start Casting:</h3>
|
||||
<p>- Press "start" next to the device you've added.</p>
|
||||
<br>
|
||||
|
||||
<h3>7. Play Your Video:</h3>
|
||||
<p>- Start any video in Grayjay to cast.</p>
|
||||
<br>
|
||||
|
||||
<h3>Finding Your IP Address:</h3>
|
||||
<p><b>On FCast Receiver (Android):</b> Displayed on the main screen.<br>
|
||||
<b>On Windows:</b> Use 'ipconfig' in Command Prompt.<br>
|
||||
<b>On Linux:</b> Use 'hostname -I' or 'ip addr' in Terminal.<br>
|
||||
<b>On MacOS:</b> System Preferences > Network.</p>
|
||||
""".trimIndent()
|
||||
|
||||
text = Html.fromHtml(guideText, Html.FROM_HTML_MODE_COMPACT)
|
||||
}
|
||||
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
UIDialogs.showCastingTutorialDialog(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
|
||||
startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to open browser.", e)
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
|
||||
startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to open browser.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
UIDialogs.showCastingTutorialDialog(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FCastGuideActivity";
|
||||
}
|
||||
}
|
||||
@@ -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.*
|
||||
@@ -15,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -23,13 +26,25 @@ import kotlinx.serialization.json.Json
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _textUrl: TextView;
|
||||
private lateinit var _buttonClose: ImageButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
finish();
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
@@ -60,6 +75,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.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
@@ -23,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.dialogs.ConnectCastingDialog
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
@@ -43,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -88,6 +88,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||
lateinit var _fragMainSuggestions: SuggestionsFragment;
|
||||
lateinit var _fragMainSubscriptions: CreatorsFragment;
|
||||
lateinit var _fragMainComments: CommentsFragment;
|
||||
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
|
||||
lateinit var _fragMainChannel: ChannelFragment;
|
||||
lateinit var _fragMainSources: SourcesFragment;
|
||||
@@ -121,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
val content = it.contents
|
||||
if (content == null) {
|
||||
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
|
||||
return@let
|
||||
}
|
||||
|
||||
try {
|
||||
handleUrlAll(content)
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to handle URL.", e)
|
||||
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor() : super() {
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||
val writer = StringWriter();
|
||||
@@ -154,6 +173,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);
|
||||
@@ -198,6 +222,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
|
||||
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
|
||||
_fragMainSubscriptions = CreatorsFragment.newInstance();
|
||||
_fragMainComments = CommentsFragment.newInstance();
|
||||
_fragMainChannel = ChannelFragment.newInstance();
|
||||
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
|
||||
_fragMainSources = SourcesFragment.newInstance();
|
||||
@@ -275,6 +300,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
//Set top bars
|
||||
_fragMainHome.topBar = _fragTopBarGeneral;
|
||||
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
|
||||
_fragMainComments.topBar = _fragTopBarGeneral;
|
||||
_fragMainSuggestions.topBar = _fragTopBarSearch;
|
||||
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
|
||||
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
|
||||
@@ -321,6 +347,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fragCurrent.onOrientationChanged(it);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_fragVideoDetail.onOrientationChanged(it);
|
||||
else if(Settings.instance.other.bypassRotationPrevention)
|
||||
{
|
||||
requestedOrientation = when(orientation) {
|
||||
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
}
|
||||
}
|
||||
};
|
||||
_orientationManager.enable();
|
||||
|
||||
@@ -390,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
|
||||
}*/
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.captureActivity = QRCaptureActivity::class.java
|
||||
_urlQrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to handle show QR scanner.", e)
|
||||
UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
@@ -459,6 +511,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "View Received: " + targetData);
|
||||
}
|
||||
}
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
}
|
||||
"IMPORT_OPTIONS" -> {
|
||||
UIDialogs.showImportOptionsDialog(this);
|
||||
}
|
||||
"TAB" -> {
|
||||
when(intent.getStringExtra("TAB")){
|
||||
"Sources" -> {
|
||||
@@ -473,72 +532,95 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
when(intent.scheme) {
|
||||
"grayjay" -> {
|
||||
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.");
|
||||
|
||||
if(fragCurrent is BuyFragment)
|
||||
closeSegment(fragCurrent);
|
||||
}
|
||||
else
|
||||
UIDialogs.toast("Invalid license format");
|
||||
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://plugin/")) {
|
||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(targetData.substring("grayjay://plugin/".length));
|
||||
};
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
"content" -> {
|
||||
if(!handleContent(targetData, intent.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown content format [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
"file" -> {
|
||||
if(!handleFile(targetData)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown file format [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
"polycentric" -> {
|
||||
if(!handlePolycentric(targetData)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown Polycentric format [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (!handleUrl(targetData)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown url format [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
}
|
||||
handleUrlAll(targetData)
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex);
|
||||
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrlAll(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
if(url.startsWith("grayjay://license/")) {
|
||||
if(StatePayment.instance.setPaymentLicenseUrl(url))
|
||||
{
|
||||
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(getString(R.string.invalid_license_format));
|
||||
|
||||
}
|
||||
else if(url.startsWith("grayjay://plugin/")) {
|
||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(url.substring("grayjay://plugin/".length));
|
||||
};
|
||||
startActivity(intent);
|
||||
}
|
||||
else if(url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
}
|
||||
else if(url.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = url.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
"content" -> {
|
||||
if(!handleContent(url, intent.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
"file" -> {
|
||||
if(!handleFile(url)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_file_format) + " [${url}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
"polycentric" -> {
|
||||
if(!handlePolycentric(url)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_polycentric_format) + " [${url}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
"fcast" -> {
|
||||
if(!handleFCast(url)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_cast,
|
||||
"Unknown FCast format [${url}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (!handleUrl(url)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_url_format) + " [${url}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +661,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 +681,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 +691,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 +709,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;
|
||||
@@ -631,22 +733,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
|
||||
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
|
||||
|
||||
val jsonSubs = newPipeSubsParsed["subscriptions"]
|
||||
val jsonSubsArray = jsonSubs.asJsonArray;
|
||||
val jsonSubsArrayItt = jsonSubsArray.iterator();
|
||||
val subs = mutableListOf<String>()
|
||||
while(jsonSubsArrayItt.hasNext()) {
|
||||
val jsonSubObj = jsonSubsArrayItt.next().asJsonObject;
|
||||
|
||||
if(jsonSubObj.has("url"))
|
||||
subs.add(jsonSubObj["url"].asString);
|
||||
}
|
||||
|
||||
navigate(_fragImportSubscriptions, subs);
|
||||
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -668,6 +759,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
|
||||
return true;
|
||||
}
|
||||
|
||||
fun handleFCast(url: String): Boolean {
|
||||
Logger.i(TAG, "handleFCast");
|
||||
|
||||
try {
|
||||
StateCasting.instance.handleUrl(this, url)
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun readSharedContent(contentPath: String): ByteArray {
|
||||
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
|
||||
return it.readBytes();
|
||||
@@ -741,6 +846,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
|
||||
@@ -834,15 +942,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
||||
navigate(fragBeforeOverlay!!, null, false, true);
|
||||
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
val last = _queue.lastOrNull();
|
||||
if (last != null) {
|
||||
_queue.remove(last);
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else
|
||||
finish();
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
finish();
|
||||
} else {
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,6 +973,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
||||
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
||||
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
||||
CommentsFragment::class -> _fragMainComments as T;
|
||||
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
|
||||
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
|
||||
ChannelFragment::class -> _fragMainChannel as T;
|
||||
@@ -925,5 +1039,19 @@ 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;
|
||||
}
|
||||
|
||||
fun getImportOptionsIntent(context: Context): Intent {
|
||||
val sourcesIntent = Intent(context, MainActivity::class.java);
|
||||
sourcesIntent.action = "IMPORT_OPTIONS";
|
||||
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
return sourcesIntent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.ItemMoveCallback
|
||||
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
|
||||
private lateinit var _recyclerTabs: RecyclerView;
|
||||
private lateinit var _touchHelper: ItemTouchHelper;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_manage_tabs);
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.*
|
||||
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_backup);
|
||||
@@ -53,7 +58,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
|
||||
_imageQR.setImageBitmap(qrCodeBitmap);
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to generate QR code", e);
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
|
||||
_imageQR.visibility = View.INVISIBLE;
|
||||
_textQR.visibility = View.INVISIBLE;
|
||||
}
|
||||
@@ -63,12 +68,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
type = "text/plain";
|
||||
putExtra(Intent.EXTRA_TEXT, _exportBundle);
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, "Share Text"));
|
||||
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
|
||||
};
|
||||
|
||||
_buttonCopy.onClick.subscribe {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
|
||||
val clip = ClipData.newPlainText("Copied Text", _exportBundle);
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
};
|
||||
}
|
||||
|
||||
+13
-4
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -9,8 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
|
||||
private var _creating = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_create_profile);
|
||||
@@ -54,7 +61,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 +75,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 {
|
||||
processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to fully backfill servers.");
|
||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonImportProfile: BigButton;
|
||||
private lateinit var _layoutButtons: LinearLayout;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_home);
|
||||
@@ -47,7 +53,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
||||
this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt());
|
||||
};
|
||||
profileButton.withPrimaryText(systemState.username);
|
||||
profileButton.withSecondaryText("Sign in to this identity");
|
||||
profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
|
||||
profileButton.onClick.subscribe {
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
|
||||
|
||||
+11
-9
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.*
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
@@ -59,7 +65,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 +76,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 +91,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 +107,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
if (existingProcessSecret != null) {
|
||||
UIDialogs.toast(this, "This profile is already imported");
|
||||
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,15 +130,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() {
|
||||
|
||||
}
|
||||
}
|
||||
+21
-11
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -16,7 +17,9 @@ 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.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
@@ -28,6 +31,7 @@ import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.toURLInfoDataLink
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,6 +50,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _imagePolycentric: ImageView;
|
||||
private var _avatarUri: Uri? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_profile);
|
||||
@@ -72,7 +80,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 +109,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 +130,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 +151,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 +194,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
|
||||
if (hasChanges) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
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 +229,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 +245,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
} else if (resultCode == ImagePicker.RESULT_ERROR) {
|
||||
UIDialogs.toast(this, ImagePicker.getError(data));
|
||||
} else {
|
||||
UIDialogs.toast(this, "Image picker cancelled");
|
||||
UIDialogs.toast(this, getString(R.string.image_picker_cancelled));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,12 +8,17 @@ import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class PolycentricWhyActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonVideo: BigButton;
|
||||
private lateinit var _buttonTechnical: BigButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_why);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
class QRCaptureActivity : CaptureActivity() {
|
||||
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -13,7 +15,8 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -21,13 +24,19 @@ import com.google.android.material.button.MaterialButton
|
||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
private lateinit var _loader: Loader;
|
||||
private lateinit var _loaderView: LoaderView;
|
||||
|
||||
private lateinit var _devSets: LinearLayout;
|
||||
private lateinit var _buttonDev: MaterialButton;
|
||||
|
||||
private var _isFinished = false;
|
||||
|
||||
lateinit var overlay: FrameLayout;
|
||||
|
||||
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);
|
||||
@@ -37,12 +46,18 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_buttonDev = findViewById(R.id.button_dev);
|
||||
_devSets = findViewById(R.id.dev_settings);
|
||||
_loader = findViewById(R.id.loader);
|
||||
_loaderView = findViewById(R.id.loader);
|
||||
overlay = findViewById(R.id.overlay_container);
|
||||
|
||||
_form.onChanged.subscribe { field, value ->
|
||||
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();
|
||||
@@ -58,9 +73,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
}
|
||||
|
||||
fun reloadSettings() {
|
||||
_loader.start();
|
||||
_form.setSearchVisible(false);
|
||||
_loaderView.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loader.stop();
|
||||
_loaderView.stop();
|
||||
_form.setSearchVisible(true);
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
@@ -70,7 +87,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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedReader
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.StringWriter
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class HttpContext : AutoCloseable {
|
||||
private val _stream: BufferedReader;
|
||||
private val _inputStream: InputStream;
|
||||
private var _responseStream: OutputStream? = null;
|
||||
|
||||
|
||||
var id: String? = null;
|
||||
|
||||
|
||||
var head: String = "";
|
||||
var headers: HttpHeaders = HttpHeaders();
|
||||
|
||||
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
|
||||
private val _responseHeaders: HttpHeaders = HttpHeaders();
|
||||
|
||||
|
||||
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
|
||||
_stream = stream;
|
||||
constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
|
||||
_inputStream = inputStream;
|
||||
_responseStream = responseStream;
|
||||
this.id = requestId;
|
||||
|
||||
try {
|
||||
head = stream.readLine() ?: throw EmptyRequestException("No head found");
|
||||
}
|
||||
catch(ex: SocketTimeoutException) {
|
||||
if((timeout ?: 0) > 0)
|
||||
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
val methodEndIndex = head.indexOf(' ');
|
||||
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
|
||||
if (methodEndIndex == -1 || urlEndIndex == -1) {
|
||||
Logger.w(TAG, "Skipped request, wrong format.");
|
||||
throw IllegalStateException("Invalid request");
|
||||
}
|
||||
|
||||
method = head.substring(0, methodEndIndex);
|
||||
path = head.substring(methodEndIndex + 1, urlEndIndex);
|
||||
|
||||
if (path.contains("?")) {
|
||||
val queryPartIndex = path.indexOf("?");
|
||||
val queryParts = path.substring(queryPartIndex + 1).split("&");
|
||||
path = path.substring(0, queryPartIndex);
|
||||
|
||||
for(queryPart in queryParts) {
|
||||
val eqIndex = queryPart.indexOf("=");
|
||||
if(eqIndex > 0)
|
||||
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
|
||||
else
|
||||
query.put(queryPart, "");
|
||||
val headerBytes = readHeaderBytes()
|
||||
ByteArrayInputStream(headerBytes).use {
|
||||
val reader = it.bufferedReader(Charsets.UTF_8)
|
||||
try {
|
||||
head = reader.readLine() ?: throw EmptyRequestException("No head found");
|
||||
}
|
||||
catch(ex: SocketTimeoutException) {
|
||||
if((timeout ?: 0) > 0)
|
||||
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val line = stream.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
val methodEndIndex = head.indexOf(' ');
|
||||
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
|
||||
if (methodEndIndex == -1 || urlEndIndex == -1) {
|
||||
Logger.w(TAG, "Skipped request, wrong format.");
|
||||
throw IllegalStateException("Invalid request");
|
||||
}
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
method = head.substring(0, methodEndIndex);
|
||||
path = head.substring(methodEndIndex + 1, urlEndIndex);
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
|
||||
"keep-alive" -> {
|
||||
val keepAliveParams = headerValue.split(",");
|
||||
for(keepAliveParam in keepAliveParams) {
|
||||
val eqIndex = keepAliveParam.indexOf("=");
|
||||
if(eqIndex > 0){
|
||||
when(keepAliveParam.substring(0, eqIndex)) {
|
||||
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
if (path.contains("?")) {
|
||||
val queryPartIndex = path.indexOf("?");
|
||||
val queryParts = path.substring(queryPartIndex + 1).split("&");
|
||||
path = path.substring(0, queryPartIndex);
|
||||
|
||||
for(queryPart in queryParts) {
|
||||
val eqIndex = queryPart.indexOf("=");
|
||||
if(eqIndex > 0)
|
||||
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
|
||||
else
|
||||
query.put(queryPart, "");
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val line = reader.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
|
||||
"keep-alive" -> {
|
||||
val keepAliveParams = headerValue.split(",");
|
||||
for(keepAliveParam in keepAliveParams) {
|
||||
val eqIndex = keepAliveParam.indexOf("=");
|
||||
if(eqIndex > 0){
|
||||
when(keepAliveParam.substring(0, eqIndex)) {
|
||||
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHeaderBytes(): ByteArray {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 4) {
|
||||
val b = _inputStream.read()
|
||||
if (b == -1) {
|
||||
throw IOException("Unexpected end of stream while reading headers")
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
}
|
||||
|
||||
headerBytes.write(b)
|
||||
}
|
||||
|
||||
return headerBytes.toByteArray()
|
||||
}
|
||||
|
||||
fun readContentBytes(buffer: ByteArray, length: Int): Int {
|
||||
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
|
||||
val read = _inputStream.read(buffer, 0, remainingBytes);
|
||||
if (read > 0) {
|
||||
_totalRead += read
|
||||
}
|
||||
|
||||
return read;
|
||||
}
|
||||
fun readContentString(): String {
|
||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
var read: Int
|
||||
while (true) {
|
||||
read = readContentBytes(buffer, buffer.size)
|
||||
if (read <= 0) break
|
||||
byteArrayOutputStream.write(buffer, 0, read)
|
||||
}
|
||||
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
return Serializer.json.decodeFromString(readContentString());
|
||||
}
|
||||
fun skipBody() {
|
||||
if (contentLength > 0)
|
||||
_inputStream.skip(contentLength - _totalRead)
|
||||
}
|
||||
|
||||
fun getHttpHeaderString(): String {
|
||||
val writer = StringWriter();
|
||||
writer.write(head + "\r\n");
|
||||
@@ -139,8 +197,13 @@ class HttpContext : AutoCloseable {
|
||||
}
|
||||
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
||||
val bytes = body?.toByteArray(Charsets.UTF_8);
|
||||
if(body != null && headers.get("content-length").isNullOrEmpty())
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
if(headers.get("content-length").isNullOrEmpty()) {
|
||||
if (body != null) {
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
} else {
|
||||
headers.put("content-length", "0")
|
||||
}
|
||||
}
|
||||
respond(status, headers) { responseStream ->
|
||||
if(body != null) {
|
||||
responseStream.write(bytes!!);
|
||||
@@ -161,8 +224,7 @@ class HttpContext : AutoCloseable {
|
||||
headersToRespond.put("keep-alive", "timeout=5, max=1000");
|
||||
}
|
||||
|
||||
val responseHeader = HttpResponse(status, headers);
|
||||
|
||||
val responseHeader = HttpResponse(status, headersToRespond);
|
||||
responseStream.write(responseHeader.getHttpHeaderBytes());
|
||||
|
||||
if(method != "HEAD") {
|
||||
@@ -172,38 +234,9 @@ class HttpContext : AutoCloseable {
|
||||
statusCode = status;
|
||||
}
|
||||
|
||||
fun readContentBytes(buffer: CharArray, length: Int) : Int {
|
||||
val reading = Math.min(length, (contentLength - _totalRead).toInt());
|
||||
val read = _stream.read(buffer, 0, reading);
|
||||
_totalRead += read;
|
||||
|
||||
//TODO: Fix this properly
|
||||
if(contentLength - _totalRead < 400 && read < length) {
|
||||
_totalRead = contentLength;
|
||||
}
|
||||
return read;
|
||||
}
|
||||
fun readContentString() : String{
|
||||
val writer = StringWriter();
|
||||
var read = 0;
|
||||
val buffer = CharArray(4096);
|
||||
do {
|
||||
read = readContentBytes(buffer, buffer.size);
|
||||
writer.write(buffer, 0, read);
|
||||
} while(read > 0);
|
||||
return writer.toString();
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
return Serializer.json.decodeFromString(readContentString());
|
||||
}
|
||||
fun skipBody() {
|
||||
if(contentLength > 0)
|
||||
_stream.skip(contentLength - _totalRead);
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if(!keepAlive) {
|
||||
_stream?.close();
|
||||
_inputStream.close();
|
||||
_responseStream?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
@@ -18,6 +18,7 @@ import java.util.*
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
private val _client : ManagedHttpClient = ManagedHttpClient();
|
||||
@@ -29,7 +30,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
var port = 0
|
||||
private set;
|
||||
|
||||
private val _handlers = mutableListOf<HttpHandler>();
|
||||
private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
|
||||
private val _headHandlers = hashMapOf<String, HttpHandler>()
|
||||
private var _workerPool: ExecutorService? = null;
|
||||
|
||||
@Synchronized
|
||||
@@ -76,12 +78,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
|
||||
private fun handleClientRequest(socket: Socket) {
|
||||
_workerPool?.submit {
|
||||
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
val requestStream = BufferedInputStream(socket.getInputStream());
|
||||
val responseStream = socket.getOutputStream();
|
||||
|
||||
val requestId = UUID.randomUUID().toString().substring(0, 5);
|
||||
try {
|
||||
keepAliveLoop(requestReader, responseStream, requestId) { req ->
|
||||
keepAliveLoop(requestStream, responseStream, requestId) { req ->
|
||||
req.use { httpContext ->
|
||||
if(!httpContext.path.startsWith("/plugin/"))
|
||||
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
|
||||
@@ -107,7 +109,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
Logger.e(TAG, "Failed to handle client request.", e);
|
||||
}
|
||||
finally {
|
||||
requestReader.close();
|
||||
requestStream.close();
|
||||
responseStream.close();
|
||||
}
|
||||
};
|
||||
@@ -115,32 +117,78 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
|
||||
fun getHandler(method: String, path: String) : HttpHandler? {
|
||||
synchronized(_handlers) {
|
||||
//TODO: Support regex paths?
|
||||
if(method == "HEAD")
|
||||
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") }
|
||||
return _handlers.firstOrNull { it.method == method && it.path == path };
|
||||
if (method == "HEAD") {
|
||||
return _headHandlers[path]
|
||||
}
|
||||
|
||||
val handlerMap = _handlers[method] ?: return null
|
||||
return handlerMap[path]
|
||||
}
|
||||
}
|
||||
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||
synchronized(_handlers) {
|
||||
_handlers.add(handler);
|
||||
handler.allowHEAD = withHEAD;
|
||||
|
||||
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
|
||||
if (handlerMap == null) {
|
||||
handlerMap = hashMapOf()
|
||||
_handlers[handler.method] = handlerMap
|
||||
}
|
||||
|
||||
handlerMap[handler.path] = handler;
|
||||
if (handler.allowHEAD || handler.method == "HEAD") {
|
||||
_headHandlers[handler.path] = handler
|
||||
}
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
fun addHandlerWithAllowAllOptions(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||
val allowedMethods = arrayListOf(handler.method, "OPTIONS")
|
||||
if (withHEAD) {
|
||||
allowedMethods.add("HEAD")
|
||||
}
|
||||
|
||||
val tag = handler.tag
|
||||
if (tag != null) {
|
||||
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods).withTag(tag))
|
||||
} else {
|
||||
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods))
|
||||
}
|
||||
|
||||
return addHandler(handler, withHEAD)
|
||||
}
|
||||
|
||||
fun removeHandler(method: String, path: String) {
|
||||
synchronized(_handlers) {
|
||||
val handler = getHandler(method, path);
|
||||
if(handler != null)
|
||||
_handlers.remove(handler);
|
||||
val handlerMap = _handlers[method] ?: return
|
||||
val handler = handlerMap.remove(path) ?: return
|
||||
if (method == "HEAD" || handler.allowHEAD) {
|
||||
_headHandlers.remove(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun removeAllHandlers(tag: String? = null) {
|
||||
synchronized(_handlers) {
|
||||
if(tag == null)
|
||||
_handlers.clear();
|
||||
else
|
||||
_handlers.removeIf { it.tag == tag };
|
||||
else {
|
||||
for (pair in _handlers) {
|
||||
val toRemove = ArrayList<String>()
|
||||
for (innerPair in pair.value) {
|
||||
if (innerPair.value.tag == tag) {
|
||||
toRemove.add(innerPair.key)
|
||||
|
||||
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
|
||||
_headHandlers.remove(innerPair.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (x in toRemove)
|
||||
pair.value.remove(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun addBridgeHandlers(obj: Any, tag: String? = null) {
|
||||
@@ -188,7 +236,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
|
||||
private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
|
||||
val stopCount = _stopCount;
|
||||
var keepAlive = false;
|
||||
var requestsMax = 0;
|
||||
|
||||
-1
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
|
||||
val headers = this.headers.clone();
|
||||
if(contentType != null)
|
||||
headers["Content-Type"] = contentType;
|
||||
headers["Content-Length"] = content.length.toString();
|
||||
|
||||
httpContext.respondCode(200, headers, content);
|
||||
}
|
||||
|
||||
+13
-22
@@ -1,14 +1,16 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) {
|
||||
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
val requestHeaders = httpContext.headers;
|
||||
val responseHeaders = this.headers.clone();
|
||||
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
|
||||
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
|
||||
|
||||
val acceptEncoding = requestHeaders["Accept-Encoding"]
|
||||
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
|
||||
if (shouldGzip) {
|
||||
responseHeaders["Content-Encoding"] = "gzip"
|
||||
}
|
||||
|
||||
val range = requestHeaders["Range"]
|
||||
var start: Long
|
||||
val start: Long
|
||||
val end: Long
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
val parts = range.substring(6).split("-")
|
||||
start = parts[0].toLong()
|
||||
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1)
|
||||
end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
|
||||
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
|
||||
} else {
|
||||
start = 0
|
||||
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
|
||||
var totalBytesSent = 0
|
||||
val contentLength = end - start + 1
|
||||
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
|
||||
responseHeaders["Content-Length"] = contentLength.toString()
|
||||
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
|
||||
|
||||
file.inputStream().use { inputStream ->
|
||||
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream ->
|
||||
httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
|
||||
try {
|
||||
val buffer = ByteArray(8192)
|
||||
inputStream.skip(start)
|
||||
var current = start
|
||||
|
||||
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream
|
||||
val outputStream = responseStream
|
||||
while (true) {
|
||||
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong());
|
||||
val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
|
||||
val bytesRead = inputStream.read(buffer);
|
||||
if (bytesRead < 0) {
|
||||
Logger.i(TAG, "End of file reached")
|
||||
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
outputStream.write(buffer, 0, bytesToSend)
|
||||
|
||||
totalBytesSent += bytesToSend
|
||||
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||
|
||||
start += bytesToSend.toLong()
|
||||
if (start >= end) {
|
||||
current += bytesToSend.toLong()
|
||||
if (current >= end) {
|
||||
Logger.i(TAG, "Expected amount of bytes sent")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Finished sending file (segment)")
|
||||
|
||||
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
|
||||
outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
httpContext.respondCode(500, headers)
|
||||
}
|
||||
}
|
||||
|
||||
if (closeAfterRequest) {
|
||||
httpContext.keepAlive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
|
||||
headers.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
|
||||
|
||||
fun withTag(tag: String) : HttpHandler {
|
||||
|
||||
+9
-10
@@ -2,19 +2,18 @@ package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
|
||||
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
|
||||
class HttpOptionsAllowHandler(path: String, val allowedMethods: List<String> = listOf()) : HttpHandler("OPTIONS", path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
//Just allow whatever is requested
|
||||
val newHeaders = headers.clone()
|
||||
newHeaders.put("Access-Control-Allow-Origin", "*")
|
||||
|
||||
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", "");
|
||||
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
|
||||
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
|
||||
|
||||
val newHeaders = headers.clone();
|
||||
newHeaders.put("Allow", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*");
|
||||
if (allowedMethods.isNotEmpty()) {
|
||||
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
|
||||
} else {
|
||||
newHeaders.put("Access-Control-Allow-Methods", "*")
|
||||
}
|
||||
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*")
|
||||
httpContext.respondCode(200, newHeaders);
|
||||
}
|
||||
}
|
||||
+160
-6
@@ -1,11 +1,20 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.Exception
|
||||
import java.net.Socket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) {
|
||||
class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
|
||||
var content: String? = null;
|
||||
var contentType: String? = null;
|
||||
|
||||
@@ -17,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
private var _injectHost = false;
|
||||
private var _injectReferer = false;
|
||||
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
handleWithTcp(context)
|
||||
} else {
|
||||
handleWithOkHttp(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWithOkHttp(context: HttpContext) {
|
||||
val proxyHeaders = HashMap<String, String>();
|
||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||
proxyHeaders[header.key] = header.value;
|
||||
@@ -34,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
//Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}");
|
||||
//Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
};
|
||||
|
||||
//Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) });
|
||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||
for(newHeader in headers)
|
||||
headersFiltered.put(newHeader.key, newHeader.value);
|
||||
|
||||
@@ -65,6 +81,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWithTcp(context: HttpContext) {
|
||||
if (content != null)
|
||||
throw NotImplementedError("Content body is not supported")
|
||||
|
||||
val proxyHeaders = HashMap<String, String>();
|
||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||
proxyHeaders[header.key] = header.value;
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
|
||||
}
|
||||
|
||||
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
|
||||
val requestBuilder = StringBuilder()
|
||||
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
|
||||
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
|
||||
requestBuilder.append("\r\n")
|
||||
|
||||
val port = if (parsed.port == -1) {
|
||||
when (parsed.scheme) {
|
||||
"https" -> 443
|
||||
"http" -> 80
|
||||
else -> throw Exception("Unhandled scheme")
|
||||
}
|
||||
} else {
|
||||
parsed.port
|
||||
}
|
||||
|
||||
val socket = if (parsed.scheme == "https") {
|
||||
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
sslSocketFactory.createSocket(parsed.host, port)
|
||||
} else {
|
||||
Socket(parsed.host, port)
|
||||
}
|
||||
|
||||
socket.use { s ->
|
||||
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
|
||||
|
||||
val inputStream = s.getInputStream()
|
||||
val resp = HttpResponseParser(inputStream)
|
||||
if (resp.statusCode == 302) {
|
||||
val location = resp.location!!
|
||||
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
|
||||
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
|
||||
} else {
|
||||
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
|
||||
val contentLength = resp.contentLength.toInt()
|
||||
|
||||
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||
for (newHeader in headers)
|
||||
headersFiltered.put(newHeader.key, newHeader.value);
|
||||
|
||||
context.respond(resp.statusCode, headersFiltered) { responseStream ->
|
||||
if (isChunked) {
|
||||
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
|
||||
handleChunkedTransfer(inputStream, responseStream)
|
||||
} else if (contentLength > 0) {
|
||||
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
|
||||
transferFixedLengthContent(inputStream, responseStream, contentLength)
|
||||
} else if (contentLength == -1) {
|
||||
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
|
||||
transferUntilEndOfStream(inputStream, responseStream)
|
||||
} else {
|
||||
Logger.i(TAG, "handleWithTcp no content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
|
||||
var line: String?
|
||||
val buffer = ByteArray(8192)
|
||||
|
||||
while (inputStream.readLine().also { line = it } != null) {
|
||||
val size = line!!.trim().toInt(16)
|
||||
|
||||
responseStream.write(line!!.encodeToByteArray())
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
|
||||
if (size == 0) {
|
||||
inputStream.skip(2)
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
break
|
||||
}
|
||||
|
||||
var totalRead = 0
|
||||
while (totalRead < size) {
|
||||
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
|
||||
if (read == -1) break
|
||||
responseStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
}
|
||||
|
||||
inputStream.skip(2)
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
responseStream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
|
||||
val buffer = ByteArray(8192)
|
||||
var totalRead = 0
|
||||
while (totalRead < contentLength) {
|
||||
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
|
||||
if (read == -1) break
|
||||
responseStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
}
|
||||
|
||||
responseStream.flush()
|
||||
}
|
||||
|
||||
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } >= 0) {
|
||||
responseStream.write(buffer, 0, read)
|
||||
}
|
||||
|
||||
responseStream.flush()
|
||||
}
|
||||
|
||||
fun withContent(body: String) : HttpProxyHandler {
|
||||
this.content = body;
|
||||
return this;
|
||||
@@ -92,4 +242,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
_ignoreRequestHeaders.add("referer");
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class ResultCapabilities(
|
||||
const val TYPE_LIVE = "LIVE";
|
||||
const val TYPE_POSTS = "POSTS";
|
||||
const val TYPE_MIXED = "MIXED";
|
||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||
|
||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ class Thumbnails {
|
||||
fun getLQThumbnail() : String? {
|
||||
return sources.firstOrNull()?.url;
|
||||
}
|
||||
fun getMinimumThumbnail(quality: Int): String? {
|
||||
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
|
||||
}
|
||||
|
||||
fun hasMultiple() = sources.size > 1;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.futo.platformplayer.api.media.models.chapters
|
||||
|
||||
import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
|
||||
interface IChapter {
|
||||
val name: String;
|
||||
val type: ChapterType;
|
||||
val timeStart: Double;
|
||||
val timeEnd: Double;
|
||||
}
|
||||
|
||||
enum class ChapterType(val value: Int) {
|
||||
NORMAL(0),
|
||||
|
||||
SKIPPABLE(5),
|
||||
SKIP(6);
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): ChapterType
|
||||
{
|
||||
val result = ChapterType.values().firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -4,10 +4,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.Pointer
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import userpackage.Protocol.Reference
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
|
||||
override val replyCount: Int?;
|
||||
|
||||
val eventPointer: Pointer;
|
||||
val reference: Reference;
|
||||
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) {
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
|
||||
this.contextUrl = contextUrl;
|
||||
this.author = author;
|
||||
this.message = msg;
|
||||
this.rating = rating;
|
||||
this.date = date;
|
||||
this.replyCount = replyCount;
|
||||
this.reference = reference;
|
||||
this.eventPointer = eventPointer;
|
||||
this.reference = eventPointer.toReference();
|
||||
}
|
||||
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
||||
@@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
}
|
||||
|
||||
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
|
||||
|
||||
NESTED_VIDEO(11),
|
||||
|
||||
LOCKED(70),
|
||||
|
||||
PLACEHOLDER(90),
|
||||
DEFERRED(91);
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.futo.platformplayer.api.media.models.locked
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
||||
interface IPlatformLockedContent: IPlatformContent {
|
||||
val lockContentType: ContentType;
|
||||
val lockDescription: String?;
|
||||
val unlockUrl: String?;
|
||||
val contentName: String?;
|
||||
val contentThumbnails: Thumbnails;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
import android.net.Uri
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
|
||||
class HLSVariantVideoUrlSource(
|
||||
override val name: String,
|
||||
override val width: Int,
|
||||
override val height: Int,
|
||||
override val container: String,
|
||||
override val codec: String,
|
||||
override val bitrate: Int?,
|
||||
override val duration: Long,
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
) : IVideoUrlSource {
|
||||
override fun getVideoUrl(): String {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
class HLSVariantAudioUrlSource(
|
||||
override val name: String,
|
||||
override val bitrate: Int,
|
||||
override val container: String,
|
||||
override val codec: String,
|
||||
override val language: String,
|
||||
override val duration: Long?,
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
) : IAudioUrlSource {
|
||||
override fun getAudioUrl(): String {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
class HLSVariantSubtitleUrlSource(
|
||||
override val name: String,
|
||||
override val url: String,
|
||||
override val format: String,
|
||||
) : ISubtitleSource {
|
||||
override val hasFetch: Boolean = false
|
||||
|
||||
override fun getSubtitles(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun getSubtitlesURI(): Uri? {
|
||||
return Uri.parse(url)
|
||||
}
|
||||
}
|
||||
+6
@@ -2,12 +2,17 @@ package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.SerialName
|
||||
|
||||
@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class)
|
||||
interface SerializedPlatformContent: IPlatformContent {
|
||||
override val contentType: ContentType;
|
||||
|
||||
fun toJson() : String;
|
||||
fun fromJson(str : String) : SerializedPlatformContent;
|
||||
fun fromJsonArray(str : String) : Array<SerializedPlatformContent>;
|
||||
@@ -18,6 +23,7 @@ interface SerializedPlatformContent: IPlatformContent {
|
||||
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
|
||||
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
|
||||
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
|
||||
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
|
||||
else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
|
||||
};
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class SerializedPlatformLockedContent(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?,
|
||||
override val url: String,
|
||||
override val shareUrl: String,
|
||||
override val lockContentType: ContentType,
|
||||
override val contentName: String?,
|
||||
override val lockDescription: String? = null,
|
||||
override val unlockUrl: String? = null,
|
||||
override val contentThumbnails: Thumbnails
|
||||
) : IPlatformLockedContent, SerializedPlatformContent {
|
||||
override val contentType: ContentType = ContentType.LOCKED;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
override fun fromJson(str : String) : SerializedPlatformLockedContent {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
|
||||
}
|
||||
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
|
||||
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
|
||||
return SerializedPlatformLockedContent(
|
||||
content.id,
|
||||
content.name,
|
||||
content.author,
|
||||
content.datetime,
|
||||
content.url,
|
||||
content.shareUrl,
|
||||
content.lockContentType,
|
||||
content.contentName,
|
||||
content.lockDescription,
|
||||
content.unlockUrl,
|
||||
content.contentThumbnails
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
|
||||
override val contentProvider: String?,
|
||||
override val contentThumbnails: Thumbnails
|
||||
) : IPlatformNestedContent, SerializedPlatformContent {
|
||||
final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
|
||||
final override val contentType: ContentType = ContentType.NESTED_VIDEO;
|
||||
|
||||
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
|
||||
override val contentSupported: Boolean get() = contentPlugin != null;
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -26,7 +27,7 @@ open class SerializedPlatformPost(
|
||||
override val thumbnails: List<Thumbnails?>,
|
||||
override val images: List<String>
|
||||
) : IPlatformPost, SerializedPlatformContent {
|
||||
final override val contentType: ContentType get() = ContentType.POST;
|
||||
override val contentType: ContentType = ContentType.POST;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ open class SerializedPlatformVideo(
|
||||
override val duration: Long,
|
||||
override val viewCount: Long,
|
||||
) : IPlatformVideo, SerializedPlatformContent {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
override val contentType: ContentType = ContentType.MEDIA;
|
||||
|
||||
override val isLive: Boolean = false;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+52
-28
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||
_jsClient = jsClient;
|
||||
_jsConfig = config;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
|
||||
@@ -31,8 +35,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 +54,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 +80,41 @@ 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)
|
||||
if(_jsClient != null)
|
||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
|
||||
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
|
||||
|
||||
return newBuilder?.let { it.build() } ?: request;
|
||||
}
|
||||
|
||||
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 +140,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) {
|
||||
|
||||
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
|
||||
ContentType.POST -> JSPost(config, obj);
|
||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSChapter : IChapter {
|
||||
override val name: String;
|
||||
override val type: ChapterType;
|
||||
override val timeStart: Double;
|
||||
override val timeEnd: Double;
|
||||
|
||||
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
|
||||
this.name = name;
|
||||
this.timeStart = timeStart;
|
||||
this.timeEnd = timeEnd;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject): IChapter {
|
||||
val context = "Chapter";
|
||||
|
||||
val name = obj.getOrThrow<String>(config,"name", context);
|
||||
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
|
||||
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
|
||||
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
|
||||
|
||||
return JSChapter(name, timeStart, timeEnd, type);
|
||||
}
|
||||
|
||||
fun fromV8(config: IV8PluginConfig, arr: V8ValueArray): List<IChapter> {
|
||||
return arr.keys.mapNotNull {
|
||||
val obj = arr.get<V8ValueObject>(it);
|
||||
return@mapNotNull fromV8(config, obj);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
|
||||
//TODO: Refactor into video-only
|
||||
class JSLockedContent: IPlatformLockedContent, JSContent {
|
||||
|
||||
override val contentType: ContentType get() = ContentType.LOCKED;
|
||||
override val lockContentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override val lockDescription: String?;
|
||||
override val unlockUrl: String?;
|
||||
|
||||
override val contentName: String?;
|
||||
override val contentThumbnails: Thumbnails;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformLockedContent";
|
||||
|
||||
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
|
||||
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
|
||||
return@let Thumbnails.fromV8(config, it);
|
||||
} ?: Thumbnails();
|
||||
|
||||
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
|
||||
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -58,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
|
||||
val previousResults = _lastResults?.let {
|
||||
if(!_resultChanged)
|
||||
return@let it;
|
||||
@@ -69,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
if(previousResults != null)
|
||||
return previousResults;
|
||||
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.futo.platformplayer.api.media.structures
|
||||
|
||||
class AdhocPager<T>: IPager<T> {
|
||||
private var _page = 0;
|
||||
private val _nextPage: (Int) -> List<T>;
|
||||
private var _currentResults: List<T> = listOf();
|
||||
private var _hasMore = true;
|
||||
|
||||
constructor(nextPage: (Int) -> List<T>, initialResults: List<T>? = null){
|
||||
_nextPage = nextPage;
|
||||
if(initialResults != null)
|
||||
_currentResults = initialResults;
|
||||
else
|
||||
nextPage();
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _hasMore;
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
val newResults = _nextPage(++_page);
|
||||
if(newResults.isEmpty())
|
||||
_hasMore = false;
|
||||
_currentResults = newResults;
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
return _currentResults;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
||||
val sameItems = results.filter { isSameItem(result, it) };
|
||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
||||
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first()
|
||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
|
||||
|
||||
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
||||
}
|
||||
@@ -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,33 +4,34 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
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.Settings
|
||||
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
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateNotifications
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import 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();
|
||||
}
|
||||
@@ -45,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
this.setSound(null, null);
|
||||
};
|
||||
notificationManager.createNotificationChannel(notificationChannel);
|
||||
val contentChannel = StateNotifications.instance.contentNotifChannel
|
||||
notificationManager.createNotificationChannel(contentChannel);
|
||||
try {
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel);
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
exception = ex;
|
||||
@@ -68,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
}
|
||||
|
||||
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("Grayjay")
|
||||
.setContentText("Updating subscriptions...")
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id)
|
||||
.setChannelId(backgroundChannel.id)
|
||||
.setProgress(1, 0, true);
|
||||
|
||||
manager.notify(12, notif.build());
|
||||
@@ -83,8 +86,12 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
|
||||
val newSubChanges = hashSetOf<Subscription>();
|
||||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val threeDays = now.minusDays(4);
|
||||
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 +104,51 @@ 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) {
|
||||
if(content.datetime != null) {
|
||||
if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification)
|
||||
StateNotifications.instance.scheduleContentNotification(applicationContext, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
newItems.add(content);
|
||||
}
|
||||
});
|
||||
|
||||
//Only for testing notifications
|
||||
val testNotifs = 0;
|
||||
if(contentNotifs.size == 0 && testNotifs > 0) {
|
||||
results.first.getResults().filter { it is IPlatformVideo }
|
||||
.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);
|
||||
StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("BackgroundWorker", "Failed to create notif", ex);
|
||||
}
|
||||
}
|
||||
/*
|
||||
manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.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());*/
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.futo.platformplayer.builders
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
class HlsBuilder {
|
||||
companion object{
|
||||
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
|
||||
val hlsBuilder = StringWriter()
|
||||
PrintWriter(hlsBuilder).use { writer ->
|
||||
writer.println("#EXTM3U")
|
||||
|
||||
// Audio
|
||||
if (audioSource != null && audioUrl != null) {
|
||||
val audioFormat = audioSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"")
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (subtitleSource != null && subtitleUrl != null) {
|
||||
val subtitleFormat = subtitleSource.format ?: "text/vtt"
|
||||
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"")
|
||||
}
|
||||
|
||||
// Video
|
||||
val videoFormat = vidSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
|
||||
writer.println(vidUrl.replace("&", "&"))
|
||||
}
|
||||
|
||||
return hlsBuilder.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package com.futo.platformplayer.cache
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.resolveChannelUrl
|
||||
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.toSafeFileName
|
||||
import com.futo.polycentric.core.toUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChannelContentCache {
|
||||
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()) });
|
||||
|
||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
|
||||
val validStores = _channelContents
|
||||
.filter { it.key == validID }
|
||||
.map { it.value };
|
||||
|
||||
val items = validStores.flatMap { it.getItems() }
|
||||
.sortedByDescending { it.datetime };
|
||||
return PlatformContentPager(items, Math.min(150, items.size));
|
||||
}
|
||||
fun getSubscriptionCachePager(): DedupContentPager {
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
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))
|
||||
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
|
||||
else
|
||||
return@map otherUrls;
|
||||
}.flatten().distinct();
|
||||
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = _channelContents
|
||||
.filter { validSubIds.contains(it.key) }
|
||||
.map { it.value };
|
||||
|
||||
val items = validStores.flatMap { it.getItems() }
|
||||
.sortedByDescending { it.datetime };
|
||||
|
||||
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { cacheContent(it) };
|
||||
}
|
||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||
if(content.author.url.isEmpty())
|
||||
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);
|
||||
}
|
||||
return@synchronized channelStore;
|
||||
}
|
||||
val serialized = SerializedPlatformContent.fromContent(content);
|
||||
val existing = store.findItems { it.url == content.url };
|
||||
|
||||
if(existing.isEmpty() || doUpdate) {
|
||||
if(existing.isNotEmpty())
|
||||
existing.forEach { store.delete(it) };
|
||||
|
||||
store.save(serialized);
|
||||
}
|
||||
|
||||
return existing.isEmpty();
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ChannelCache";
|
||||
|
||||
private val _lock = Object();
|
||||
private var _instance: ChannelContentCache? = null;
|
||||
val instance: ChannelContentCache get() {
|
||||
synchronized(_lock) {
|
||||
if(_instance == null)
|
||||
_instance = ChannelContentCache();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
|
||||
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
return ChannelVideoCachePager(pager, scope, onNewCacheHit);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelVideoCachePager(val pager: IPager<IPlatformContent>, private val scope: CoroutineScope, private val onNewCacheItem: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
|
||||
init {
|
||||
val results = pager.getResults();
|
||||
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to cache videos.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return pager.hasMorePages();
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
pager.nextPage();
|
||||
val results = pager.getResults();
|
||||
|
||||
Logger.i(TAG, "Caching ${results.size} subscription results");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to cache videos.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getResults(): List<IPlatformContent> {
|
||||
val results = pager.getResults();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
|
||||
import android.os.Looper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
@@ -49,7 +48,7 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
|
||||
time = resumePosition;
|
||||
if (resumePosition > 0.0) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.net.InetAddress
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -14,10 +19,27 @@ enum class CastConnectionState {
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FASTCAST
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDevice {
|
||||
|
||||
@@ -2,18 +2,16 @@ package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.protos.DeviceAuthMessageOuterClass
|
||||
import com.futo.platformplayer.protos.ChromeCast
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.*
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
@@ -69,7 +67,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
|
||||
time = resumePosition;
|
||||
_streamType = streamType;
|
||||
@@ -314,6 +312,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
@@ -324,7 +323,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
@@ -375,7 +374,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = DeviceAuthMessageOuterClass.CastMessage.parseFrom(messageBytes);
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
@@ -428,12 +427,12 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||
try {
|
||||
val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder()
|
||||
.setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0)
|
||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
||||
.setSourceId(sourceId)
|
||||
.setDestinationId(destinationId)
|
||||
.setNamespace(namespace)
|
||||
.setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING)
|
||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
||||
.setPayloadUtf8(json)
|
||||
.build();
|
||||
|
||||
@@ -447,8 +446,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) {
|
||||
if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) {
|
||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
||||
val jsonObject = JSONObject(message.payloadUtf8);
|
||||
val type = jsonObject.getString("type");
|
||||
if (type == "RECEIVER_STATUS") {
|
||||
|
||||
+9
-9
@@ -30,10 +30,10 @@ enum class Opcode(val value: Byte) {
|
||||
SET_VOLUME(8)
|
||||
}
|
||||
|
||||
class FastCastCastingDevice : CastingDevice {
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FASTCAST;
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
@@ -72,7 +72,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
|
||||
time = resumePosition;
|
||||
sendMessage(Opcode.PLAY, FastCastPlayMessage(
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition.toInt()
|
||||
@@ -87,7 +87,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
|
||||
|
||||
time = resumePosition;
|
||||
sendMessage(Opcode.PLAY, FastCastPlayMessage(
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition.toInt()
|
||||
@@ -100,7 +100,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
this.volume = volume
|
||||
sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume))
|
||||
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
@@ -108,7 +108,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.SEEK, FastCastSeekMessage(
|
||||
sendMessage(Opcode.SEEK, FCastSeekMessage(
|
||||
time = timeSeconds.toInt()
|
||||
));
|
||||
}
|
||||
@@ -282,7 +282,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackUpdate = Json.decodeFromString<FastCastPlaybackUpdateMessage>(json);
|
||||
val playbackUpdate = Json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
||||
time = playbackUpdate.time.toDouble();
|
||||
isPlaying = when (playbackUpdate.state) {
|
||||
1 -> true
|
||||
@@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
val volumeUpdate = Json.decodeFromString<FastCastVolumeUpdateMessage>(json);
|
||||
val volumeUpdate = Json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||
volume = volumeUpdate.volume;
|
||||
}
|
||||
else -> { }
|
||||
@@ -398,7 +398,7 @@ class FastCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.FASTCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -2,8 +2,12 @@ package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||
import com.futo.platformplayer.api.http.server.handlers.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
@@ -12,8 +16,10 @@ 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.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.InetAddress
|
||||
@@ -24,6 +30,9 @@ import javax.jmdns.ServiceListener
|
||||
import kotlin.collections.HashMap
|
||||
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.jmdns.ServiceTypeListener
|
||||
|
||||
class StateCasting {
|
||||
@@ -44,6 +53,7 @@ class StateCasting {
|
||||
val onActiveDevicePlayChanged = Event1<Boolean>();
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
@@ -143,6 +153,32 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(context: Context, url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "fcast") {
|
||||
throw Exception("Expected scheme to be FCast")
|
||||
}
|
||||
|
||||
val type = uri.host
|
||||
if (type != "r") {
|
||||
throw Exception("Expected type r")
|
||||
}
|
||||
|
||||
val connectionInfo = uri.pathSegments[0]
|
||||
val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8)
|
||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||
|
||||
addRememberedDevice(CastingDeviceInfo(
|
||||
name = networkConfig.name,
|
||||
type = CastProtocolType.FCAST,
|
||||
addresses = networkConfig.addresses.toTypedArray(),
|
||||
port = tcpService.port
|
||||
))
|
||||
|
||||
UIDialogs.toast(context,"FCast device '${networkConfig.name}' added")
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
val ad = activeDevice ?: return;
|
||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||
@@ -330,20 +366,25 @@ class StateCasting {
|
||||
}
|
||||
|
||||
if (sourceCount > 1) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); };
|
||||
ad.stopCasting();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
}
|
||||
} else {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (ad is FastCastCastingDevice) {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -353,15 +394,40 @@ class StateCasting {
|
||||
}
|
||||
} else {
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition);
|
||||
} else {
|
||||
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource");
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
|
||||
).filterNotNull().joinToString(", ");
|
||||
throw UnsupportedCastException(str);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,15 +458,23 @@ class StateCasting {
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun castVideoIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castAudioIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -414,12 +488,12 @@ class StateCasting {
|
||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -430,11 +504,106 @@ class StateCasting {
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
val videoPath = "/video-${id}"
|
||||
val audioPath = "/audio-${id}"
|
||||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val hlsUrl = url + hlsPath
|
||||
val videoUrl = url + videoPath
|
||||
val audioUrl = url + audioPath
|
||||
val subtitleUrl = url + subtitlePath
|
||||
|
||||
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = videoSource.duration
|
||||
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
|
||||
videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null)))
|
||||
}
|
||||
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
|
||||
}
|
||||
|
||||
if (subtitleSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
|
||||
}
|
||||
|
||||
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
|
||||
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
|
||||
|
||||
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
|
||||
}
|
||||
|
||||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -447,43 +616,28 @@ class StateCasting {
|
||||
val audioUrl = url + audioPath;
|
||||
val subtitleUrl = url + subtitlePath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
||||
"application/dash+xml")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(videoPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(audioPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (subtitleSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true)
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(subtitlePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
|
||||
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
|
||||
@@ -495,7 +649,7 @@ class StateCasting {
|
||||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val subtitlePath = "/subtitle-${id}";
|
||||
|
||||
@@ -517,7 +671,7 @@ class StateCasting {
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -537,13 +691,311 @@ class StateCasting {
|
||||
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
||||
}
|
||||
|
||||
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
val hlsUrl = url + hlsPath
|
||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
||||
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
||||
|
||||
val headers = masterContext.headers.clone()
|
||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val masterPlaylistResponse = _client.get(sourceUrl)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
} catch (e: Throwable) {
|
||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
//This is a variant playlist, not a master playlist
|
||||
Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl");
|
||||
|
||||
val vpHeaders = masterContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
return@HttpFuntionHandler
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "HLS casting as master playlist: $hlsUrl");
|
||||
|
||||
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
|
||||
|
||||
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
|
||||
val playlistId = UUID.randomUUID();
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
val newPlaylistUrl = url + newPlaylistPath;
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val response = _client.get(variantPlaylistRef.url)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
|
||||
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
||||
newPlaylistUrl,
|
||||
variantPlaylistRef.streamInfo
|
||||
))
|
||||
}
|
||||
|
||||
for (mediaRendition in masterPlaylist.mediaRenditions) {
|
||||
val playlistId = UUID.randomUUID()
|
||||
|
||||
var newPlaylistUrl: String? = null
|
||||
if (mediaRendition.uri != null) {
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
newPlaylistUrl = url + newPlaylistPath
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val response = _client.get(mediaRendition.uri)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
}
|
||||
|
||||
newMediaRenditions.add(HLS.MediaRendition(
|
||||
mediaRendition.type,
|
||||
newPlaylistUrl,
|
||||
mediaRendition.groupID,
|
||||
mediaRendition.language,
|
||||
mediaRendition.name,
|
||||
mediaRendition.isDefault,
|
||||
mediaRendition.isAutoSelect,
|
||||
mediaRendition.isForced
|
||||
))
|
||||
}
|
||||
|
||||
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
|
||||
|
||||
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
|
||||
|
||||
//ChromeCast is sometimes funky with resume position 0
|
||||
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
|
||||
|
||||
return listOf(hlsUrl);
|
||||
}
|
||||
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
val newSegments = arrayListOf<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||
}
|
||||
} else {
|
||||
newSegments.addAll(variantPlaylist.segments)
|
||||
}
|
||||
|
||||
return HLS.VariantPlaylist(
|
||||
variantPlaylist.version,
|
||||
variantPlaylist.targetDuration,
|
||||
variantPlaylist.mediaSequence,
|
||||
variantPlaylist.discontinuitySequence,
|
||||
variantPlaylist.programDateTime,
|
||||
variantPlaylist.playlistType,
|
||||
variantPlaylist.streamInfo,
|
||||
newSegments
|
||||
)
|
||||
}
|
||||
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||
if (segment is HLS.MediaSegment) {
|
||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||
val newSegmentUrl = url + newSegmentPath;
|
||||
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castProxiedHlsVariant")
|
||||
}
|
||||
|
||||
return HLS.MediaSegment(
|
||||
segment.duration,
|
||||
newSegmentUrl
|
||||
)
|
||||
} else {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
||||
val hlsUrl = url + hlsPath;
|
||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||
|
||||
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
if (audioSource != null) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath
|
||||
|
||||
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
}
|
||||
|
||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||
return@withContext subtitleSource.getSubtitlesURI();
|
||||
} else null;
|
||||
|
||||
var subtitlesUrl: String? = null;
|
||||
if (subtitlesUri != null) {
|
||||
val subtitlePath = "/subtitles-${id}"
|
||||
if(subtitlesUri.scheme == "file") {
|
||||
var content: String? = null;
|
||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||
inputStream?.use { stream ->
|
||||
val reader = stream.bufferedReader();
|
||||
content = reader.use { it.readText() };
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
}
|
||||
|
||||
subtitlesUrl = url + subtitlePath;
|
||||
} else {
|
||||
subtitlesUrl = subtitlesUri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitlesUrl != null) {
|
||||
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
|
||||
}
|
||||
|
||||
if (videoSource != null) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath
|
||||
|
||||
val duration = videoSource.duration
|
||||
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
|
||||
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
|
||||
videoSource.bitrate ?: 0,
|
||||
"${videoSource.width}x${videoSource.height}",
|
||||
videoSource.codec,
|
||||
null,
|
||||
null,
|
||||
if (audioSource != null) "audio" else null,
|
||||
if (subtitleSource != null) "subtitles" else null,
|
||||
null, null)))
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
}
|
||||
|
||||
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectMaster")
|
||||
|
||||
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
|
||||
|
||||
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = ad !is FastCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
Logger.i(TAG, "DASH url: $url");
|
||||
val proxyStreams = ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -552,6 +1004,8 @@ class StateCasting {
|
||||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val dashUrl = url + dashPath;
|
||||
Logger.i(TAG, "DASH url: $dashUrl");
|
||||
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
||||
|
||||
@@ -573,7 +1027,7 @@ class StateCasting {
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -585,38 +1039,29 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
||||
"application/dash+xml")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(videoPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(audioPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alivcontexte"))
|
||||
.withTag("cast");
|
||||
}
|
||||
|
||||
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
|
||||
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
||||
|
||||
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
@@ -629,8 +1074,8 @@ class StateCasting {
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
AirPlayCastingDevice(deviceInfo);
|
||||
}
|
||||
CastProtocolType.FASTCAST -> {
|
||||
FastCastCastingDevice(deviceInfo);
|
||||
CastProtocolType.FCAST -> {
|
||||
FCastCastingDevice(deviceInfo);
|
||||
}
|
||||
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
|
||||
}
|
||||
@@ -677,8 +1122,8 @@ class StateCasting {
|
||||
}
|
||||
|
||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice<FastCastCastingDevice>(name,
|
||||
deviceFactory = { FastCastCastingDevice(name, addresses, port) },
|
||||
return addOrUpdateCastDevice<FCastCastingDevice>(name,
|
||||
deviceFactory = { FCastCastingDevice(name, addresses, port) },
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
@@ -754,6 +1199,19 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FCastNetworkConfig(
|
||||
val name: String,
|
||||
val addresses: List<String>,
|
||||
val services: List<FCastService>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FCastService(
|
||||
val port: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
val instance: StateCasting = StateCasting();
|
||||
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@ package com.futo.platformplayer.casting.models
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class FastCastPlayMessage(
|
||||
data class FCastPlayMessage(
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
@@ -11,23 +11,23 @@ data class FastCastPlayMessage(
|
||||
) { }
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class FastCastSeekMessage(
|
||||
data class FCastSeekMessage(
|
||||
val time: Int
|
||||
) { }
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class FastCastPlaybackUpdateMessage(
|
||||
data class FCastPlaybackUpdateMessage(
|
||||
val time: Int,
|
||||
val state: Int
|
||||
) { }
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FastCastVolumeUpdateMessage(
|
||||
data class FCastVolumeUpdateMessage(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FastCastSetVolumeMessage(
|
||||
data class FCastSetVolumeMessage(
|
||||
val volume: Double
|
||||
)
|
||||
@@ -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
|
||||
@@ -23,6 +24,7 @@ import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
|
||||
@@ -184,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
val config = context.readContentJson<SourcePluginConfig>()
|
||||
try {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config);
|
||||
|
||||
val client = JSHttpClient(null, null, null, config);
|
||||
val clientAuth = JSHttpClient(null, null, null, config);
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
|
||||
|
||||
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -201,6 +207,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;
|
||||
@@ -212,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
}
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it));
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
context.respondCode(200, "Login started");
|
||||
@@ -264,7 +292,6 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
@HttpPOST("/plugin/remoteCall")
|
||||
fun pluginRemoteCall(context: HttpContext) {
|
||||
try {
|
||||
val parameters = context.readContentString();
|
||||
val objId = context.query.get("id")
|
||||
val method = context.query.get("method")
|
||||
|
||||
@@ -276,16 +303,24 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(400, "Missing method");
|
||||
return;
|
||||
}
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
|
||||
val parameters = context.readContentString();
|
||||
|
||||
val remoteObj = getRemoteObject(objId);
|
||||
val paras = JsonParser.parseString(parameters);
|
||||
if(!paras.isJsonArray)
|
||||
throw IllegalArgumentException("Expected json array as body");
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
val callResult = remoteObj.call(method, paras as JsonArray);
|
||||
val json = wrapRemoteResult(callResult, false);
|
||||
context.respondCode(200, json, "application/json");
|
||||
}
|
||||
catch(invocation: InvocationTargetException) {
|
||||
val innerException = invocation.targetException;
|
||||
Logger.e("DeveloperEndpoints", innerException.message, innerException);
|
||||
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
|
||||
}
|
||||
catch(ilEx: IllegalArgumentException) {
|
||||
if(ilEx.message?.contains("does not exist") ?: false) {
|
||||
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
||||
|
||||
@@ -95,6 +95,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonUpdate.visibility = Button.GONE;
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
|
||||
Logger.i(TAG, "Keep screen on set update")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_text.text = context.resources.getText(R.string.downloading_update);
|
||||
@@ -178,6 +180,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
Logger.i(TAG, "Keep screen on unset install")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
@@ -26,6 +23,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _buttonCancel: Button;
|
||||
private lateinit var _buttonConfirm: LinearLayout;
|
||||
private lateinit var _buttonTutorial: TextView;
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -38,6 +36,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_buttonCancel = findViewById(R.id.button_cancel);
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
@@ -62,7 +61,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonConfirm.setOnClickListener {
|
||||
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
|
||||
0 -> CastProtocolType.FASTCAST
|
||||
0 -> CastProtocolType.FCAST
|
||||
1 -> CastProtocolType.CHROMECAST
|
||||
2 -> CastProtocolType.AIRPLAY
|
||||
else -> {
|
||||
@@ -105,6 +104,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
||||
performDismiss();
|
||||
};
|
||||
|
||||
_buttonTutorial.setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(context)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.FCastGuideActivity
|
||||
import com.futo.platformplayer.activities.PolycentricWhyActivity
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
|
||||
class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_help, null));
|
||||
|
||||
findViewById<BigButton>(R.id.button_guide).onClick.subscribe {
|
||||
context.startActivity(Intent(context, FCastGuideActivity::class.java))
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_video).onClick.subscribe {
|
||||
try {
|
||||
//TODO: Replace the URL with the casting video URL
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
|
||||
context.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to open browser.", e)
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
|
||||
context.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to open browser.", e)
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
|
||||
context.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to open browser.", e)
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
dismiss()
|
||||
UIDialogs.showCastingAddDialog(context)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingTutorialDialog";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -84,13 +86,20 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
if (_editComment.text.isBlank()) {
|
||||
UIDialogs.toast(context, "Comment should not be blank.");
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
val comment = _editComment.text.toString();
|
||||
val processHandle = StatePolycentric.instance.processHandle!!
|
||||
val eventPointer = processHandle.post(comment, null, ref)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers()
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServersAnnounceExceptions()
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||
}
|
||||
@@ -109,7 +118,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
msg = comment,
|
||||
rating = RatingLikeDislikes(0, 0),
|
||||
date = OffsetDateTime.now(),
|
||||
reference = eventPointer.toReference()
|
||||
eventPointer = eventPointer
|
||||
));
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.AddSourceActivity
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.QRCaptureActivity
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private lateinit var _imageLoader: ImageView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
private lateinit var _buttonAdd: Button;
|
||||
private lateinit var _buttonScanQR: Button;
|
||||
private lateinit var _textNoDevicesFound: TextView;
|
||||
private lateinit var _textNoDevicesRemembered: TextView;
|
||||
private lateinit var _recyclerDevices: RecyclerView;
|
||||
@@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonAdd = findViewById(R.id.button_add);
|
||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||
@@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
UIDialogs.showCastingAddDialog(context);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
val c = ownerActivity
|
||||
if (c is MainActivity) {
|
||||
_buttonScanQR.visibility = View.VISIBLE
|
||||
_buttonScanQR.setOnClickListener {
|
||||
c.showUrlQrCodeScanner()
|
||||
dismiss()
|
||||
};
|
||||
} else {
|
||||
_buttonScanQR.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
|
||||
@@ -16,9 +16,7 @@ import com.futo.platformplayer.casting.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.slider.Slider.OnChangeListener
|
||||
import com.google.android.material.slider.Slider.OnSliderTouchListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
@@ -105,7 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is FastCastCastingDevice) {
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FastCast";
|
||||
}
|
||||
|
||||
@@ -134,6 +134,8 @@ class ImportDialog : AlertDialog {
|
||||
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
|
||||
Logger.i(TAG, "Keep screen on set import")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_updateSpinner.drawable?.assume<Animatable>()?.start();
|
||||
@@ -201,6 +203,7 @@ class ImportDialog : AlertDialog {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update import UI.", e)
|
||||
} finally {
|
||||
Logger.i(TAG, "Keep screen on unset update")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Button
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.readBytes
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class ImportOptionsDialog: AlertDialog {
|
||||
private val _context: MainActivity;
|
||||
|
||||
private lateinit var _button_import_zip: BigButton;
|
||||
private lateinit var _button_import_ezip: BigButton;
|
||||
private lateinit var _button_import_txt: BigButton;
|
||||
private lateinit var _button_import_newpipe_subs: BigButton;
|
||||
private lateinit var _button_close: Button;
|
||||
|
||||
|
||||
constructor(context: MainActivity): super(context) {
|
||||
_context = context;
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_import_options, null));
|
||||
_button_import_zip = findViewById(R.id.button_import_zip);
|
||||
_button_import_ezip = findViewById(R.id.button_import_ezip);
|
||||
_button_import_txt = findViewById(R.id.button_import_txt);
|
||||
_button_import_newpipe_subs = findViewById(R.id.button_import_newpipe_subs);
|
||||
_button_close = findViewById(R.id.button_cancel);
|
||||
|
||||
_button_import_zip.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/zip") {
|
||||
val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes);
|
||||
};
|
||||
}
|
||||
_button_import_ezip.setOnClickListener {
|
||||
|
||||
}
|
||||
_button_import_txt.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "text/plain") {
|
||||
val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val txt = String(txtBytes);
|
||||
StateBackup.importTxt(_context, txt);
|
||||
};
|
||||
}
|
||||
_button_import_newpipe_subs.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/json") {
|
||||
val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val json = String(jsonBytes);
|
||||
StateBackup.importNewPipeSubs(_context, json);
|
||||
};
|
||||
};
|
||||
_button_close.setOnClickListener {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user