mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 19:13:01 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b65fc594dc | |||
| f52b731615 | |||
| 99c06c516f | |||
| 10e3d2122f |
+8
-1
@@ -5,6 +5,7 @@ plugins {
|
|||||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
id 'org.ajoberstar.grgit' version '1.7.2'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
|
id 'kotlin-kapt'
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
@@ -38,7 +39,7 @@ protobuf {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.futo.platformplayer'
|
namespace 'com.futo.platformplayer'
|
||||||
compileSdk 33
|
compileSdk 34
|
||||||
flavorDimensions "buildType"
|
flavorDimensions "buildType"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
stable {
|
stable {
|
||||||
@@ -194,6 +195,12 @@ dependencies {
|
|||||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
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
|
//Payment
|
||||||
implementation 'com.stripe:stripe-android:20.28.3'
|
implementation 'com.stripe:stripe-android:20.28.3'
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,14 @@
|
|||||||
|
|
||||||
<data android:scheme="grayjay" />
|
<data android:scheme="grayjay" />
|
||||||
</intent-filter>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@@ -210,5 +218,9 @@
|
|||||||
android:name=".activities.QRCaptureActivity"
|
android:name=".activities.QRCaptureActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.FCastGuideActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.views.adapters.CommentViewHolder
|
||||||
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
|
|||||||
|
|
||||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||||
|
val exceptions = fullyBackfillServers()
|
||||||
|
for (pair in exceptions) {
|
||||||
|
val server = pair.key
|
||||||
|
val exception = pair.value
|
||||||
|
|
||||||
|
StateAnnouncement.instance.registerAnnouncement(
|
||||||
|
"backfill-failed",
|
||||||
|
"Backfill failed",
|
||||||
|
"Failed to backfill server $server. $exception",
|
||||||
|
AnnouncementType.SESSION_RECURRING
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
//Syntax sugaring
|
//Syntax sugaring
|
||||||
inline fun <reified T> Any.assume(): T?{
|
inline fun <reified T> Any.assume(): T?{
|
||||||
if(this is T)
|
if(this is T)
|
||||||
@@ -16,4 +21,8 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
|||||||
|
|
||||||
fun String?.yesNoToBoolean(): Boolean {
|
fun String?.yesNoToBoolean(): Boolean {
|
||||||
return this?.uppercase() == "YES"
|
return this?.uppercase() == "YES"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Boolean?.toYesNo(): String {
|
||||||
|
return if (this == true) "YES" else "NO"
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import android.webkit.CookieManager
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.*
|
import com.futo.platformplayer.activities.*
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -23,6 +22,7 @@ import com.futo.platformplayer.views.fields.FormField
|
|||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -30,7 +30,6 @@ import kotlinx.serialization.*
|
|||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||||
@@ -45,19 +44,23 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Transient
|
@Transient
|
||||||
val onTabsChanged = Event0();
|
val onTabsChanged = Event0();
|
||||||
|
|
||||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
|
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
|
||||||
@FormFieldButton(R.drawable.ic_person)
|
@FormFieldButton(R.drawable.ic_person)
|
||||||
fun managePolycentricIdentity() {
|
fun managePolycentricIdentity() {
|
||||||
SettingsActivity.getActivity()?.let {
|
SettingsActivity.getActivity()?.let {
|
||||||
if (StatePolycentric.instance.processHandle != null) {
|
if (StatePolycentric.instance.enabled) {
|
||||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
if (StatePolycentric.instance.processHandle != null) {
|
||||||
|
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||||
|
} else {
|
||||||
|
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
|
||||||
|
}
|
||||||
} else {
|
} 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, -4)
|
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
|
||||||
@FormFieldButton(R.drawable.ic_quiz)
|
@FormFieldButton(R.drawable.ic_quiz)
|
||||||
fun openFAQ() {
|
fun openFAQ() {
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +70,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
//Ignored
|
//Ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
|
@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)
|
@FormFieldButton(R.drawable.ic_data_alert)
|
||||||
fun openIssues() {
|
fun openIssues() {
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +102,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
|
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
|
||||||
@FormFieldButton(R.drawable.ic_tabs)
|
@FormFieldButton(R.drawable.ic_tabs)
|
||||||
fun manageTabs() {
|
fun manageTabs() {
|
||||||
try {
|
try {
|
||||||
@@ -113,6 +116,25 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@FormField(R.string.language, "group", -1, 0)
|
||||||
var language = LanguageSettings();
|
var language = LanguageSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -158,7 +180,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
|
||||||
@FormFieldButton(R.drawable.ic_visibility_off)
|
@FormFieldButton(R.drawable.ic_visibility_off)
|
||||||
fun clearHidden() {
|
fun clearHidden() {
|
||||||
StateMeta.instance.removeAllHiddenCreators();
|
StateMeta.instance.removeAllHiddenCreators();
|
||||||
@@ -185,6 +211,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
fun getSearchFeedStyle(): FeedStyle {
|
fun getSearchFeedStyle(): FeedStyle {
|
||||||
@@ -195,7 +223,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
|
|
||||||
|
@FormField(R.string.channel, "group", -1, 3)
|
||||||
|
var channel = ChannelSettings();
|
||||||
|
@Serializable
|
||||||
|
class ChannelSettings {
|
||||||
|
|
||||||
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
|
||||||
var subscriptions = SubscriptionsSettings();
|
var subscriptions = SubscriptionsSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class SubscriptionsSettings {
|
class SubscriptionsSettings {
|
||||||
@@ -213,14 +251,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var fetchOnAppBoot: Boolean = true;
|
var fetchOnAppBoot: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 6)
|
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
|
||||||
var fetchOnTabOpen: Boolean = true;
|
var fetchOnTabOpen: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
|
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
|
||||||
@DropdownFieldOptionsId(R.array.background_interval)
|
@DropdownFieldOptionsId(R.array.background_interval)
|
||||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||||
|
|
||||||
@@ -236,7 +277,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8)
|
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
|
||||||
@DropdownFieldOptionsId(R.array.thread_count)
|
@DropdownFieldOptionsId(R.array.thread_count)
|
||||||
var subscriptionConcurrency: Int = 3;
|
var subscriptionConcurrency: Int = 3;
|
||||||
|
|
||||||
@@ -244,25 +285,25 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return threadIndexToCount(subscriptionConcurrency);
|
return threadIndexToCount(subscriptionConcurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9)
|
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
|
||||||
var showWatchMetrics: Boolean = false;
|
var showWatchMetrics: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
|
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
|
||||||
var allowPlaytimeTracking: Boolean = true;
|
var allowPlaytimeTracking: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11)
|
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
|
||||||
var alwaysReloadFromCache: Boolean = false;
|
var alwaysReloadFromCache: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12)
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
|
||||||
fun clearChannelCache() {
|
fun clearChannelCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||||
ChannelContentCache.instance.clear();
|
StateCache.instance.clear();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
|
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
|
||||||
var playback = PlaybackSettings();
|
var playback = PlaybackSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class PlaybackSettings {
|
class PlaybackSettings {
|
||||||
@@ -288,29 +329,29 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
else -> 1.0f;
|
else -> 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
|
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredQuality: Int = 0;
|
var preferredQuality: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, -1, 2)
|
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredMeteredQuality: Int = 0;
|
var preferredMeteredQuality: Int = 0;
|
||||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
||||||
|
|
||||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, -1, 3)
|
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredPreviewQuality: Int = 5;
|
var preferredPreviewQuality: Int = 5;
|
||||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 4)
|
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
||||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
||||||
var autoRotate: Int = 2;
|
var autoRotate: Int = 2;
|
||||||
|
|
||||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.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)
|
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||||
var autoRotateDeadZone: Int = 0;
|
var autoRotateDeadZone: Int = 0;
|
||||||
|
|
||||||
@@ -318,7 +359,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return autoRotateDeadZone * 5;
|
return autoRotateDeadZone * 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||||
var backgroundPlay: Int = 2;
|
var backgroundPlay: Int = 2;
|
||||||
|
|
||||||
@@ -358,18 +399,29 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
||||||
var backgroundSwitchToAudio: Boolean = true;
|
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(R.string.comments, "group", R.string.comments_description, 4)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
var comments = CommentSettings();
|
var comments = CommentSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class CommentSettings {
|
class CommentSettings {
|
||||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||||
var defaultCommentSection: Int = 0;
|
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, 5)
|
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||||
var downloads = Downloads();
|
var downloads = Downloads();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Downloads {
|
class Downloads {
|
||||||
@@ -409,7 +461,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6)
|
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
|
||||||
var browsing = Browsing();
|
var browsing = Browsing();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Browsing {
|
class Browsing {
|
||||||
@@ -418,7 +470,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var videoCache: Boolean = true;
|
var videoCache: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.casting, "group", R.string.configure_casting, 7)
|
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||||
var casting = Casting();
|
var casting = Casting();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Casting {
|
class Casting {
|
||||||
@@ -446,8 +498,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
|
||||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
|
|
||||||
var logging = Logging();
|
var logging = Logging();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Logging {
|
class Logging {
|
||||||
@@ -471,7 +522,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
|
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
|
||||||
var announcementSettings = AnnouncementSettings();
|
var announcementSettings = AnnouncementSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class AnnouncementSettings {
|
class AnnouncementSettings {
|
||||||
@@ -482,7 +533,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.notifications, FieldForm.GROUP, -1, 11)
|
@FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
|
||||||
var notifications = NotificationSettings();
|
var notifications = NotificationSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class NotificationSettings {
|
class NotificationSettings {
|
||||||
@@ -490,7 +541,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var plannedContentNotification: Boolean = true;
|
var plannedContentNotification: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 12)
|
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||||
@Transient
|
@Transient
|
||||||
var plugins = Plugins();
|
var plugins = Plugins();
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -527,7 +578,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 13)
|
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
|
||||||
var storage = Storage();
|
var storage = Storage();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Storage {
|
class Storage {
|
||||||
@@ -561,7 +612,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 14)
|
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
|
||||||
var autoUpdate = AutoUpdate();
|
var autoUpdate = AutoUpdate();
|
||||||
@Serializable
|
@Serializable
|
||||||
class AutoUpdate {
|
class AutoUpdate {
|
||||||
@@ -643,7 +694,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 15)
|
@FormField(R.string.backup, FieldForm.GROUP, -1, 16)
|
||||||
var backup = Backup();
|
var backup = Backup();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Backup {
|
class Backup {
|
||||||
@@ -675,28 +726,19 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@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)
|
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
||||||
fun export() {
|
fun export() {
|
||||||
StateBackup.startExternalBackup();
|
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(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
|
|
||||||
fun import() {
|
|
||||||
val act = SettingsActivity.getActivity() ?: return;
|
|
||||||
StateApp.instance.requestFileReadAccess(act, null) {
|
|
||||||
if(it != null && it.exists()) {
|
|
||||||
val name = it.name;
|
|
||||||
val contents = it.readBytes(act);
|
|
||||||
if(contents != null) {
|
|
||||||
if(name != null && name.endsWith(".zip", true))
|
|
||||||
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 16)
|
@FormField(R.string.payment, FieldForm.GROUP, -1, 17)
|
||||||
var payment = Payment();
|
var payment = Payment();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Payment {
|
class Payment {
|
||||||
@@ -713,16 +755,19 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.other, FieldForm.GROUP, -1, 17)
|
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
|
||||||
var other = Other();
|
var other = Other();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Other {
|
class Other {
|
||||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
||||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
||||||
var bypassRotationPrevention: Boolean = false;
|
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, 18)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||||
var info = Info();
|
var info = Info();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Info {
|
class Info {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
@@ -12,25 +13,31 @@ import androidx.work.WorkManager
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
import com.caoccao.javet.values.primitive.V8ValueString
|
||||||
|
import com.futo.platformplayer.activities.DeveloperActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
|
import com.futo.platformplayer.views.fields.ButtonField
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.FormField
|
import com.futo.platformplayer.views.fields.FormField
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -39,6 +46,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.stream.IntStream.range
|
import java.util.stream.IntStream.range
|
||||||
@@ -82,26 +90,153 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
var backgroundSubscriptionFetching: Boolean = false;
|
var backgroundSubscriptionFetching: Boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
@FormField(R.string.crash_me, FieldForm.BUTTON,
|
||||||
R.string.crashes_the_application_on_purpose, 2)
|
R.string.crashes_the_application_on_purpose, 3)
|
||||||
fun crashMe() {
|
fun crashMe() {
|
||||||
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
|
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
|
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
|
||||||
R.string.delete_all_announcements, 2)
|
R.string.delete_all_announcements, 3)
|
||||||
fun deleteAnnouncements() {
|
fun deleteAnnouncements() {
|
||||||
StateAnnouncement.instance.deleteAllAnnouncements();
|
StateAnnouncement.instance.deleteAllAnnouncements();
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON,
|
@FormField(R.string.clear_cookies, FieldForm.BUTTON,
|
||||||
R.string.clear_all_cookies_from_the_cookieManager, 2)
|
R.string.clear_all_cookies_from_the_cookieManager, 3)
|
||||||
fun clearCookies() {
|
fun clearCookies() {
|
||||||
val cookieManager: CookieManager = CookieManager.getInstance()
|
val cookieManager: CookieManager = CookieManager.getInstance()
|
||||||
cookieManager.removeAllCookies(null);
|
cookieManager.removeAllCookies(null);
|
||||||
}
|
}
|
||||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 3)
|
R.string.test_background_worker_description, 4)
|
||||||
fun triggerBackgroundUpdate() {
|
fun triggerBackgroundUpdate() {
|
||||||
val act = SettingsActivity.getActivity()!!;
|
val act = SettingsActivity.getActivity()!!;
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||||
@@ -113,10 +248,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
wm.enqueue(req);
|
wm.enqueue(req);
|
||||||
}
|
}
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 3)
|
R.string.test_background_worker_description, 4)
|
||||||
fun clearChannelContentCache() {
|
fun clearChannelContentCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||||
ChannelContentCache.instance.clearToday();
|
StateCache.instance.clearToday();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,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
|
//region BOILERPLATE
|
||||||
override fun encode(): String {
|
override fun encode(): String {
|
||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -10,12 +13,12 @@ import android.view.View
|
|||||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.dialogs.*
|
import com.futo.platformplayer.dialogs.*
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateBackup
|
import com.futo.platformplayer.states.StateBackup
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
@@ -91,6 +94,50 @@ class UIDialogs {
|
|||||||
}.toTypedArray());
|
}.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) {
|
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
||||||
val dialogAction: ()->Unit = {
|
val dialogAction: ()->Unit = {
|
||||||
@@ -107,7 +154,8 @@ class UIDialogs {
|
|||||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||||
UIDialogs.Action(context.getString(R.string.restore), {
|
UIDialogs.Action(context.getString(R.string.restore), {
|
||||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
);
|
||||||
else {
|
else {
|
||||||
dialogAction();
|
dialogAction();
|
||||||
}
|
}
|
||||||
@@ -142,8 +190,10 @@ class UIDialogs {
|
|||||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||||
if(code == null)
|
if(code == null)
|
||||||
this.visibility = View.GONE;
|
this.visibility = View.GONE;
|
||||||
else
|
else {
|
||||||
this.text = code;
|
this.text = code;
|
||||||
|
this.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
||||||
val buttons = actions.map<Action, TextView> { act ->
|
val buttons = actions.map<Action, TextView> { act ->
|
||||||
@@ -279,6 +329,12 @@ class UIDialogs {
|
|||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
fun showImportOptionsDialog(context: MainActivity) {
|
||||||
|
val dialog = ImportOptionsDialog(context);
|
||||||
|
registerDialogOpened(dialog);
|
||||||
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showCastingDialog(context: Context) {
|
fun showCastingDialog(context: Context) {
|
||||||
@@ -291,11 +347,22 @@ class UIDialogs {
|
|||||||
} else {
|
} else {
|
||||||
val dialog = ConnectCastingDialog(context);
|
val dialog = ConnectCastingDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
val c = context
|
||||||
|
if (c is Activity) {
|
||||||
|
dialog.setOwnerActivity(c);
|
||||||
|
}
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showCastingTutorialDialog(context: Context) {
|
||||||
|
val dialog = CastingHelpDialog(context);
|
||||||
|
registerDialogOpened(dialog);
|
||||||
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
fun showCastingAddDialog(context: Context) {
|
fun showCastingAddDialog(context: Context) {
|
||||||
val dialog = CastingAddDialog(context);
|
val dialog = CastingAddDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.graphics.Color
|
|
||||||
import android.util.TypedValue
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.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.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.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.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
@@ -24,8 +21,9 @@ import com.futo.platformplayer.helpers.VideoHelper
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.*
|
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.SlideUpMenuGroup
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
@@ -127,6 +125,101 @@ class UISlideOverlays {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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? {
|
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||||
val items = arrayListOf<View>();
|
val items = arrayListOf<View>();
|
||||||
var menu: SlideUpMenuOverlay? = null;
|
var menu: SlideUpMenuOverlay? = null;
|
||||||
@@ -166,30 +259,49 @@ class UISlideOverlays {
|
|||||||
videoSources
|
videoSources
|
||||||
.filter { it.isDownloadable() }
|
.filter { it.isDownloadable() }
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
if (it is IVideoUrlSource) {
|
||||||
selectedVideo = it as IVideoUrlSource;
|
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||||
menu?.selectOption(videoSources, it);
|
selectedVideo = it
|
||||||
if(selectedAudio != null || !requiresAudio)
|
menu?.selectOption(videoSources, it);
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
if(selectedAudio != null || !requiresAudio)
|
||||||
}, false)
|
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()
|
}).flatten().toList()
|
||||||
));
|
));
|
||||||
|
|
||||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
|
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
|
||||||
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
|
//TODO: Add HLS support here
|
||||||
|
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||||
|
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
|
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||||
|
) as IVideoUrlSource;
|
||||||
|
}
|
||||||
|
|
||||||
audioSources?.let { audioSources ->
|
audioSources?.let { audioSources ->
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
|
||||||
.filter { VideoHelper.isDownloadable(it) }
|
.filter { VideoHelper.isDownloadable(it) }
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
if (it is IAudioUrlSource) {
|
||||||
selectedAudio = it as IAudioUrlSource;
|
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||||
menu?.selectOption(audioSources, it);
|
selectedAudio = it
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.selectOption(audioSources, it);
|
||||||
}, false);
|
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 asources = audioSources;
|
||||||
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
||||||
@@ -198,15 +310,15 @@ class UISlideOverlays {
|
|||||||
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
||||||
menu?.selectOption(asources, preferredAudioSource);
|
menu?.selectOption(asources, preferredAudioSource);
|
||||||
|
|
||||||
|
//TODO: Add HLS support here
|
||||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
|
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
|
||||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||||
}
|
}
|
||||||
|
|
||||||
//ContentResolver is required for subtitles..
|
//ContentResolver is required for subtitles..
|
||||||
if(contentResolver != null) {
|
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||||
@@ -378,7 +490,7 @@ class UISlideOverlays {
|
|||||||
val dp70 = 70.dp(container.context.resources);
|
val dp70 = 70.dp(container.context.resources);
|
||||||
val dp15 = 15.dp(container.context.resources);
|
val dp15 = 15.dp(container.context.resources);
|
||||||
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
|
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);
|
this.setPadding(0, dp15, 0, dp15);
|
||||||
}
|
}
|
||||||
), true);
|
), true);
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
package com.futo.platformplayer.activities
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
|
import com.futo.platformplayer.views.fields.IField
|
||||||
|
|
||||||
class DeveloperActivity : AppCompatActivity() {
|
class DeveloperActivity : AppCompatActivity() {
|
||||||
private lateinit var _form: FieldForm;
|
private lateinit var _form: FieldForm;
|
||||||
private lateinit var _buttonBack: ImageButton;
|
private lateinit var _buttonBack: ImageButton;
|
||||||
|
|
||||||
|
fun getField(id: String): IField? {
|
||||||
|
return _form.findField(id);
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
DeveloperActivity._lastActivity = this;
|
||||||
setContentView(R.layout.activity_dev);
|
setContentView(R.layout.activity_dev);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
|
|
||||||
@@ -33,4 +40,19 @@ class DeveloperActivity : AppCompatActivity() {
|
|||||||
super.finish()
|
super.finish()
|
||||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,7 +7,7 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.preference.PreferenceManager
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
@@ -24,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
|
||||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event3
|
import com.futo.platformplayer.dialogs.ConnectCastingDialog
|
||||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||||
@@ -44,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
|||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -89,6 +88,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||||
lateinit var _fragMainSuggestions: SuggestionsFragment;
|
lateinit var _fragMainSuggestions: SuggestionsFragment;
|
||||||
lateinit var _fragMainSubscriptions: CreatorsFragment;
|
lateinit var _fragMainSubscriptions: CreatorsFragment;
|
||||||
|
lateinit var _fragMainComments: CommentsFragment;
|
||||||
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
|
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
|
||||||
lateinit var _fragMainChannel: ChannelFragment;
|
lateinit var _fragMainChannel: ChannelFragment;
|
||||||
lateinit var _fragMainSources: SourcesFragment;
|
lateinit var _fragMainSources: SourcesFragment;
|
||||||
@@ -122,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
private var _isVisible = true;
|
private var _isVisible = true;
|
||||||
private var _wasStopped = false;
|
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() {
|
constructor() : super() {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||||
val writer = StringWriter();
|
val writer = StringWriter();
|
||||||
@@ -204,6 +222,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
|
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
|
||||||
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
|
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
|
||||||
_fragMainSubscriptions = CreatorsFragment.newInstance();
|
_fragMainSubscriptions = CreatorsFragment.newInstance();
|
||||||
|
_fragMainComments = CommentsFragment.newInstance();
|
||||||
_fragMainChannel = ChannelFragment.newInstance();
|
_fragMainChannel = ChannelFragment.newInstance();
|
||||||
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
|
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
|
||||||
_fragMainSources = SourcesFragment.newInstance();
|
_fragMainSources = SourcesFragment.newInstance();
|
||||||
@@ -281,6 +300,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
//Set top bars
|
//Set top bars
|
||||||
_fragMainHome.topBar = _fragTopBarGeneral;
|
_fragMainHome.topBar = _fragTopBarGeneral;
|
||||||
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
|
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
|
||||||
|
_fragMainComments.topBar = _fragTopBarGeneral;
|
||||||
_fragMainSuggestions.topBar = _fragTopBarSearch;
|
_fragMainSuggestions.topBar = _fragTopBarSearch;
|
||||||
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
|
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
|
||||||
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
|
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
|
||||||
@@ -405,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
|
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() {
|
override fun onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
Logger.v(TAG, "onResume")
|
Logger.v(TAG, "onResume")
|
||||||
@@ -478,6 +515,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
val url = intent.getStringExtra("VIDEO");
|
val url = intent.getStringExtra("VIDEO");
|
||||||
navigate(_fragVideoDetail, url);
|
navigate(_fragVideoDetail, url);
|
||||||
}
|
}
|
||||||
|
"IMPORT_OPTIONS" -> {
|
||||||
|
UIDialogs.showImportOptionsDialog(this);
|
||||||
|
}
|
||||||
"TAB" -> {
|
"TAB" -> {
|
||||||
when(intent.getStringExtra("TAB")){
|
when(intent.getStringExtra("TAB")){
|
||||||
"Sources" -> {
|
"Sources" -> {
|
||||||
@@ -492,76 +532,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (targetData != null) {
|
if (targetData != null) {
|
||||||
when(intent.scheme) {
|
handleUrlAll(targetData)
|
||||||
"grayjay" -> {
|
|
||||||
if(targetData.startsWith("grayjay://license/")) {
|
|
||||||
if(StatePayment.instance.setPaymentLicenseUrl(targetData))
|
|
||||||
{
|
|
||||||
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(targetData.startsWith("grayjay://plugin/")) {
|
|
||||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
|
||||||
data = Uri.parse(targetData.substring("grayjay://plugin/".length));
|
|
||||||
};
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
else if(targetData.startsWith("grayjay://video/")) {
|
|
||||||
val videoUrl = targetData.substring("grayjay://video/".length);
|
|
||||||
navigate(_fragVideoDetail, videoUrl);
|
|
||||||
}
|
|
||||||
else if(targetData.startsWith("grayjay://channel/")) {
|
|
||||||
val channelUrl = targetData.substring("grayjay://channel/".length);
|
|
||||||
navigate(_fragMainChannel, channelUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"content" -> {
|
|
||||||
if(!handleContent(targetData, intent.type)) {
|
|
||||||
UIDialogs.showSingleButtonDialog(
|
|
||||||
this,
|
|
||||||
R.drawable.ic_play,
|
|
||||||
getString(R.string.unknown_content_format) + " [${targetData}]",
|
|
||||||
"Ok",
|
|
||||||
{ });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"file" -> {
|
|
||||||
if(!handleFile(targetData)) {
|
|
||||||
UIDialogs.showSingleButtonDialog(
|
|
||||||
this,
|
|
||||||
R.drawable.ic_play,
|
|
||||||
getString(R.string.unknown_file_format) + " [${targetData}]",
|
|
||||||
"Ok",
|
|
||||||
{ });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"polycentric" -> {
|
|
||||||
if(!handlePolycentric(targetData)) {
|
|
||||||
UIDialogs.showSingleButtonDialog(
|
|
||||||
this,
|
|
||||||
R.drawable.ic_play,
|
|
||||||
getString(R.string.unknown_polycentric_format) + " [${targetData}]",
|
|
||||||
"Ok",
|
|
||||||
{ });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (!handleUrl(targetData)) {
|
|
||||||
UIDialogs.showSingleButtonDialog(
|
|
||||||
this,
|
|
||||||
R.drawable.ic_play,
|
|
||||||
getString(R.string.unknown_url_format) + " [${targetData}]",
|
|
||||||
"Ok",
|
|
||||||
{ });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@@ -569,6 +540,90 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
{ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleUrl(url: String): Boolean {
|
fun handleUrl(url: String): Boolean {
|
||||||
Logger.i(TAG, "handleUrl(url=$url)")
|
Logger.i(TAG, "handleUrl(url=$url)")
|
||||||
|
|
||||||
@@ -678,18 +733,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
|
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
|
||||||
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
|
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
|
||||||
|
|
||||||
val jsonSubs = newPipeSubsParsed["subscriptions"]
|
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
catch(ex: Exception) {
|
catch(ex: Exception) {
|
||||||
Logger.e(TAG, ex.message, ex);
|
Logger.e(TAG, ex.message, ex);
|
||||||
@@ -715,6 +759,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
|
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
|
||||||
return true;
|
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 {
|
private fun readSharedContent(contentPath: String): ByteArray {
|
||||||
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
|
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
|
||||||
return it.readBytes();
|
return it.readBytes();
|
||||||
@@ -884,15 +942,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
||||||
navigate(fragBeforeOverlay!!, null, false, true);
|
navigate(fragBeforeOverlay!!, null, false, true);
|
||||||
|
} else {
|
||||||
}
|
|
||||||
else {
|
|
||||||
val last = _queue.lastOrNull();
|
val last = _queue.lastOrNull();
|
||||||
if (last != null) {
|
if (last != null) {
|
||||||
_queue.remove(last);
|
_queue.remove(last);
|
||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else
|
} else {
|
||||||
finish();
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
finish();
|
||||||
|
} else {
|
||||||
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
|
finish();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +973,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
||||||
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
||||||
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
||||||
|
CommentsFragment::class -> _fragMainComments as T;
|
||||||
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
|
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
|
||||||
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
|
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
|
||||||
ChannelFragment::class -> _fragMainChannel as T;
|
ChannelFragment::class -> _fragMainChannel as T;
|
||||||
@@ -982,5 +1046,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
return sourcesIntent;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@@ -82,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers();
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.dialogs.CommentDialog
|
import com.futo.platformplayer.dialogs.CommentDialog
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
@@ -194,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers();
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
@@ -15,7 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.Loader
|
import com.futo.platformplayer.views.LoaderView
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -23,13 +24,15 @@ import com.google.android.material.button.MaterialButton
|
|||||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||||
private lateinit var _form: FieldForm;
|
private lateinit var _form: FieldForm;
|
||||||
private lateinit var _buttonBack: ImageButton;
|
private lateinit var _buttonBack: ImageButton;
|
||||||
private lateinit var _loader: Loader;
|
private lateinit var _loaderView: LoaderView;
|
||||||
|
|
||||||
private lateinit var _devSets: LinearLayout;
|
private lateinit var _devSets: LinearLayout;
|
||||||
private lateinit var _buttonDev: MaterialButton;
|
private lateinit var _buttonDev: MaterialButton;
|
||||||
|
|
||||||
private var _isFinished = false;
|
private var _isFinished = false;
|
||||||
|
|
||||||
|
lateinit var overlay: FrameLayout;
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
@@ -43,7 +46,8 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||||||
_buttonBack = findViewById(R.id.button_back);
|
_buttonBack = findViewById(R.id.button_back);
|
||||||
_buttonDev = findViewById(R.id.button_dev);
|
_buttonDev = findViewById(R.id.button_dev);
|
||||||
_devSets = findViewById(R.id.dev_settings);
|
_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 ->
|
_form.onChanged.subscribe { field, value ->
|
||||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||||
@@ -69,9 +73,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reloadSettings() {
|
fun reloadSettings() {
|
||||||
_loader.start();
|
_form.setSearchVisible(false);
|
||||||
|
_loaderView.start();
|
||||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||||
_loader.stop();
|
_loaderView.stop();
|
||||||
|
_form.setSearchVisible(true);
|
||||||
|
|
||||||
var devCounter = 0;
|
var devCounter = 0;
|
||||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||||
|
|||||||
@@ -197,8 +197,13 @@ class HttpContext : AutoCloseable {
|
|||||||
}
|
}
|
||||||
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
||||||
val bytes = body?.toByteArray(Charsets.UTF_8);
|
val bytes = body?.toByteArray(Charsets.UTF_8);
|
||||||
if(body != null && headers.get("content-length").isNullOrEmpty())
|
if(headers.get("content-length").isNullOrEmpty()) {
|
||||||
headers.put("content-length", bytes!!.size.toString());
|
if (body != null) {
|
||||||
|
headers.put("content-length", bytes!!.size.toString());
|
||||||
|
} else {
|
||||||
|
headers.put("content-length", "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
respond(status, headers) { responseStream ->
|
respond(status, headers) { responseStream ->
|
||||||
if(body != null) {
|
if(body != null) {
|
||||||
responseStream.write(bytes!!);
|
responseStream.write(bytes!!);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||||
|
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@@ -141,6 +142,23 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||||||
}
|
}
|
||||||
return 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) {
|
fun removeHandler(method: String, path: String) {
|
||||||
synchronized(_handlers) {
|
synchronized(_handlers) {
|
||||||
val handlerMap = _handlers[method] ?: return
|
val handlerMap = _handlers[method] ?: return
|
||||||
|
|||||||
+9
-10
@@ -2,19 +2,18 @@ package com.futo.platformplayer.api.http.server.handlers
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
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) {
|
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", "");
|
if (allowedMethods.isNotEmpty()) {
|
||||||
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
|
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
|
||||||
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
|
} else {
|
||||||
|
newHeaders.put("Access-Control-Allow-Methods", "*")
|
||||||
val newHeaders = headers.clone();
|
}
|
||||||
newHeaders.put("Allow", requestedMethods);
|
|
||||||
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
|
|
||||||
newHeaders.put("Access-Control-Allow-Headers", "*");
|
|
||||||
|
|
||||||
|
newHeaders.put("Access-Control-Allow-Headers", "*")
|
||||||
httpContext.respondCode(200, newHeaders);
|
httpContext.respondCode(200, newHeaders);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
-18
@@ -98,11 +98,15 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", targetUrl);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${targetUrl}");
|
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||||
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
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()
|
val requestBuilder = StringBuilder()
|
||||||
requestBuilder.append("$useMethod $targetUrl HTTP/1.1\r\n")
|
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
|
||||||
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
|
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
|
||||||
requestBuilder.append("\r\n")
|
requestBuilder.append("\r\n")
|
||||||
|
|
||||||
@@ -128,23 +132,31 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
|
|
||||||
val inputStream = s.getInputStream()
|
val inputStream = s.getInputStream()
|
||||||
val resp = HttpResponseParser(inputStream)
|
val resp = HttpResponseParser(inputStream)
|
||||||
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
|
if (resp.statusCode == 302) {
|
||||||
val contentLength = resp.contentLength.toInt()
|
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()) });
|
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||||
for(newHeader in headers)
|
for (newHeader in headers)
|
||||||
headersFiltered.put(newHeader.key, newHeader.value);
|
headersFiltered.put(newHeader.key, newHeader.value);
|
||||||
|
|
||||||
context.respond(resp.statusCode, headersFiltered) { responseStream ->
|
context.respond(resp.statusCode, headersFiltered) { responseStream ->
|
||||||
if (isChunked) {
|
if (isChunked) {
|
||||||
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
|
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
|
||||||
handleChunkedTransfer(inputStream, responseStream)
|
handleChunkedTransfer(inputStream, responseStream)
|
||||||
} else if (contentLength != -1) {
|
} else if (contentLength > 0) {
|
||||||
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
|
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
|
||||||
transferFixedLengthContent(inputStream, responseStream, contentLength)
|
transferFixedLengthContent(inputStream, responseStream, contentLength)
|
||||||
} else {
|
} else if (contentLength == -1) {
|
||||||
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
|
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
|
||||||
transferUntilEndOfStream(inputStream, responseStream)
|
transferUntilEndOfStream(inputStream, responseStream)
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "handleWithTcp no content");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +168,6 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
|
|
||||||
while (inputStream.readLine().also { line = it } != null) {
|
while (inputStream.readLine().also { line = it } != null) {
|
||||||
val size = line!!.trim().toInt(16)
|
val size = line!!.trim().toInt(16)
|
||||||
Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size")
|
|
||||||
|
|
||||||
responseStream.write(line!!.encodeToByteArray())
|
responseStream.write(line!!.encodeToByteArray())
|
||||||
responseStream.write("\r\n".encodeToByteArray())
|
responseStream.write("\r\n".encodeToByteArray())
|
||||||
|
|||||||
+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.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
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.Pointer
|
||||||
import com.futo.polycentric.core.SignedEvent
|
|
||||||
import userpackage.Protocol.Reference
|
import userpackage.Protocol.Reference
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
|
|||||||
|
|
||||||
override val replyCount: Int?;
|
override val replyCount: Int?;
|
||||||
|
|
||||||
|
val eventPointer: Pointer;
|
||||||
val reference: Reference;
|
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.contextUrl = contextUrl;
|
||||||
this.author = author;
|
this.author = author;
|
||||||
this.message = msg;
|
this.message = msg;
|
||||||
this.rating = rating;
|
this.rating = rating;
|
||||||
this.date = date;
|
this.date = date;
|
||||||
this.replyCount = replyCount;
|
this.replyCount = replyCount;
|
||||||
this.reference = reference;
|
this.eventPointer = eventPointer;
|
||||||
|
this.reference = eventPointer.toReference();
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
||||||
@@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
|
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 {
|
companion object {
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -6,9 +6,13 @@ import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
|||||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||||
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||||
|
import kotlinx.serialization.EncodeDefault
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class)
|
@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class)
|
||||||
interface SerializedPlatformContent: IPlatformContent {
|
interface SerializedPlatformContent: IPlatformContent {
|
||||||
|
override val contentType: ContentType;
|
||||||
|
|
||||||
fun toJson() : String;
|
fun toJson() : String;
|
||||||
fun fromJson(str : String) : SerializedPlatformContent;
|
fun fromJson(str : String) : SerializedPlatformContent;
|
||||||
fun fromJsonArray(str : String) : Array<SerializedPlatformContent>;
|
fun fromJsonArray(str : String) : Array<SerializedPlatformContent>;
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ open class SerializedPlatformLockedContent(
|
|||||||
override val unlockUrl: String? = null,
|
override val unlockUrl: String? = null,
|
||||||
override val contentThumbnails: Thumbnails
|
override val contentThumbnails: Thumbnails
|
||||||
) : IPlatformLockedContent, SerializedPlatformContent {
|
) : IPlatformLockedContent, SerializedPlatformContent {
|
||||||
final override val contentType: ContentType get() = ContentType.LOCKED;
|
override val contentType: ContentType = ContentType.LOCKED;
|
||||||
|
|
||||||
override fun toJson() : String {
|
override fun toJson() : String {
|
||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
|
|||||||
override val contentProvider: String?,
|
override val contentProvider: String?,
|
||||||
override val contentThumbnails: Thumbnails
|
override val contentThumbnails: Thumbnails
|
||||||
) : IPlatformNestedContent, SerializedPlatformContent {
|
) : 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 contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
|
||||||
override val contentSupported: Boolean get() = contentPlugin != null;
|
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.api.media.models.post.IPlatformPost
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.polycentric.core.combineHashCodes
|
import com.futo.polycentric.core.combineHashCodes
|
||||||
|
import kotlinx.serialization.EncodeDefault
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -26,7 +27,7 @@ open class SerializedPlatformPost(
|
|||||||
override val thumbnails: List<Thumbnails?>,
|
override val thumbnails: List<Thumbnails?>,
|
||||||
override val images: List<String>
|
override val images: List<String>
|
||||||
) : IPlatformPost, SerializedPlatformContent {
|
) : IPlatformPost, SerializedPlatformContent {
|
||||||
final override val contentType: ContentType get() = ContentType.POST;
|
override val contentType: ContentType = ContentType.POST;
|
||||||
|
|
||||||
override fun toJson() : String {
|
override fun toJson() : String {
|
||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ open class SerializedPlatformVideo(
|
|||||||
override val duration: Long,
|
override val duration: Long,
|
||||||
override val viewCount: Long,
|
override val viewCount: Long,
|
||||||
) : IPlatformVideo, SerializedPlatformContent {
|
) : IPlatformVideo, SerializedPlatformContent {
|
||||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
override val contentType: ContentType = ContentType.MEDIA;
|
||||||
|
|
||||||
override val isLive: Boolean = false;
|
override val isLive: Boolean = false;
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.matchesDomain
|
import com.futo.platformplayer.matchesDomain
|
||||||
|
|
||||||
class JSHttpClient : ManagedHttpClient {
|
class JSHttpClient : ManagedHttpClient {
|
||||||
private val _jsClient: JSClient?;
|
private val _jsClient: JSClient?;
|
||||||
|
private val _jsConfig: SourcePluginConfig?;
|
||||||
private val _auth: SourceAuth?;
|
private val _auth: SourceAuth?;
|
||||||
private val _captcha: SourceCaptchaData?;
|
private val _captcha: SourceCaptchaData?;
|
||||||
|
|
||||||
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
|
|
||||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||||
|
|
||||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
|
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||||
_jsClient = jsClient;
|
_jsClient = jsClient;
|
||||||
|
_jsConfig = config;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_captcha = captcha;
|
_captcha = captcha;
|
||||||
|
|
||||||
@@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
if(_jsClient != null)
|
||||||
|
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||||
|
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
|
||||||
|
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
|
||||||
|
|
||||||
return newBuilder?.let { it.build() } ?: request;
|
return newBuilder?.let { it.build() } ?: request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 sameItems = results.filter { isSameItem(result, it) };
|
||||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
||||||
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
||||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first()
|
val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
|
||||||
|
|
||||||
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
//Only for testing notifications
|
//Only for testing notifications
|
||||||
val testNotifs = 0;
|
val testNotifs = 0;
|
||||||
if(contentNotifs.size == 0 && testNotifs > 0) {
|
if(contentNotifs.size == 0 && testNotifs > 0) {
|
||||||
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
|
results.first.getResults().filter { it is IPlatformVideo }
|
||||||
.take(testNotifs).forEach {
|
.take(testNotifs).forEach {
|
||||||
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
|
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,213 +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.stores.v2.ManagedStore
|
|
||||||
import com.futo.platformplayer.toSafeFileName
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import kotlin.streams.toList
|
|
||||||
import kotlin.system.measureTimeMillis
|
|
||||||
|
|
||||||
class ChannelContentCache {
|
|
||||||
private val _targetCacheSize = 3000;
|
|
||||||
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
|
|
||||||
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
|
|
||||||
init {
|
|
||||||
val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
|
|
||||||
val initializeTime = measureTimeMillis {
|
|
||||||
_channelContents = HashMap(allFiles
|
|
||||||
.filter { it.isDirectory }
|
|
||||||
.parallelStream().map {
|
|
||||||
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
|
|
||||||
.withoutBackup()
|
|
||||||
.load())
|
|
||||||
}.toList().associate { it })
|
|
||||||
}
|
|
||||||
val minDays = OffsetDateTime.now().minusDays(10);
|
|
||||||
val totalItems = _channelContents.map { it.value.count() }.sum();
|
|
||||||
val toTrim = totalItems - _targetCacheSize;
|
|
||||||
val trimmed: Int;
|
|
||||||
if(toTrim > 0) {
|
|
||||||
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
|
|
||||||
.sortedBy { it.datetime!! }.take(toTrim);
|
|
||||||
for(content in redundantContent)
|
|
||||||
uncacheContent(content);
|
|
||||||
trimmed = redundantContent.size;
|
|
||||||
}
|
|
||||||
else trimmed = 0;
|
|
||||||
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
synchronized(_channelContents) {
|
|
||||||
for(channel in _channelContents)
|
|
||||||
for(content in channel.value.getItems())
|
|
||||||
uncacheContent(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun clearToday() {
|
|
||||||
val yesterday = OffsetDateTime.now().minusDays(1);
|
|
||||||
synchronized(_channelContents) {
|
|
||||||
for(channel in _channelContents)
|
|
||||||
for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true })
|
|
||||||
uncacheContent(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
|
||||||
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 {
|
|
||||||
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
|
||||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
|
||||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
|
||||||
val allUrls = subs.map {
|
|
||||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
|
||||||
if(!otherUrls.contains(it.channel.url))
|
|
||||||
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
|
|
||||||
else
|
|
||||||
return@map otherUrls;
|
|
||||||
}.flatten().distinct();
|
|
||||||
Logger.i(TAG, "Subscriptions CachePager compiling");
|
|
||||||
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(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uncacheContent(content: SerializedPlatformContent) {
|
|
||||||
val store = getContentStore(content);
|
|
||||||
store?.delete(content);
|
|
||||||
}
|
|
||||||
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
|
|
||||||
return contents.filter { cacheContent(it) };
|
|
||||||
}
|
|
||||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
|
||||||
if(content.author.url.isEmpty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
val channelId = content.author.url.toSafeFileName();
|
|
||||||
val store = getContentStore(channelId).let {
|
|
||||||
if(it == null) {
|
|
||||||
Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
|
|
||||||
val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
|
||||||
_channelContents.put(channelId, store);
|
|
||||||
return@let store;
|
|
||||||
}
|
|
||||||
else return@let it;
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
|
|
||||||
val channelId = content.author.url.toSafeFileName();
|
|
||||||
return getContentStore(channelId);
|
|
||||||
}
|
|
||||||
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
|
|
||||||
return synchronized(_channelContents) {
|
|
||||||
var channelStore = _channelContents.get(channelId);
|
|
||||||
return@synchronized channelStore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "ChannelCache";
|
|
||||||
|
|
||||||
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.cacheContents(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.cacheContents(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 android.os.Looper
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
import com.futo.platformplayer.getConnectedSocket
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
@@ -49,7 +48,7 @@ class AirPlayCastingDevice : CastingDevice {
|
|||||||
return;
|
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;
|
time = resumePosition;
|
||||||
if (resumePosition > 0.0) {
|
if (resumePosition > 0.0) {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.futo.platformplayer.casting
|
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.constructs.Event1
|
||||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
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.net.InetAddress
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@@ -14,10 +19,27 @@ enum class CastConnectionState {
|
|||||||
CONNECTED
|
CONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||||
enum class CastProtocolType {
|
enum class CastProtocolType {
|
||||||
CHROMECAST,
|
CHROMECAST,
|
||||||
AIRPLAY,
|
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 {
|
abstract class CastingDevice {
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ package com.futo.platformplayer.casting
|
|||||||
|
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
import com.futo.platformplayer.getConnectedSocket
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
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.toHexString
|
||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
import java.io.DataOutputStream
|
import java.io.DataOutputStream
|
||||||
import java.io.IOException
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
@@ -69,7 +67,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
return;
|
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;
|
time = resumePosition;
|
||||||
_streamType = streamType;
|
_streamType = streamType;
|
||||||
@@ -314,6 +312,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
connectionState = CastConnectionState.CONNECTING;
|
connectionState = CastConnectionState.CONNECTING;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_socket?.close()
|
||||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||||
_socket?.startHandshake();
|
_socket?.startHandshake();
|
||||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||||
@@ -324,7 +323,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Throwable) {
|
||||||
_socket?.close();
|
_socket?.close();
|
||||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
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?
|
//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));
|
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
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") {
|
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||||
Logger.i(TAG, "Received message: $message");
|
Logger.i(TAG, "Received message: $message");
|
||||||
}
|
}
|
||||||
@@ -428,12 +427,12 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||||
try {
|
try {
|
||||||
val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder()
|
val castMessage = ChromeCast.CastMessage.newBuilder()
|
||||||
.setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0)
|
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
||||||
.setSourceId(sourceId)
|
.setSourceId(sourceId)
|
||||||
.setDestinationId(destinationId)
|
.setDestinationId(destinationId)
|
||||||
.setNamespace(namespace)
|
.setNamespace(namespace)
|
||||||
.setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING)
|
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
||||||
.setPayloadUtf8(json)
|
.setPayloadUtf8(json)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -447,8 +446,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) {
|
private fun handleMessage(message: ChromeCast.CastMessage) {
|
||||||
if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) {
|
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
||||||
val jsonObject = JSONObject(message.payloadUtf8);
|
val jsonObject = JSONObject(message.payloadUtf8);
|
||||||
val type = jsonObject.getString("type");
|
val type = jsonObject.getString("type");
|
||||||
if (type == "RECEIVER_STATUS") {
|
if (type == "RECEIVER_STATUS") {
|
||||||
|
|||||||
+9
-9
@@ -30,10 +30,10 @@ enum class Opcode(val value: Byte) {
|
|||||||
SET_VOLUME(8)
|
SET_VOLUME(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
class FastCastCastingDevice : CastingDevice {
|
class FCastCastingDevice : CastingDevice {
|
||||||
//See for more info: TODO
|
//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 val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
override var usedRemoteAddress: InetAddress? = null;
|
||||||
override var localAddress: 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)");
|
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||||
|
|
||||||
time = resumePosition;
|
time = resumePosition;
|
||||||
sendMessage(Opcode.PLAY, FastCastPlayMessage(
|
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||||
container = contentType,
|
container = contentType,
|
||||||
url = contentId,
|
url = contentId,
|
||||||
time = resumePosition.toInt()
|
time = resumePosition.toInt()
|
||||||
@@ -87,7 +87,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
|
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
|
||||||
|
|
||||||
time = resumePosition;
|
time = resumePosition;
|
||||||
sendMessage(Opcode.PLAY, FastCastPlayMessage(
|
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||||
container = contentType,
|
container = contentType,
|
||||||
content = content,
|
content = content,
|
||||||
time = resumePosition.toInt()
|
time = resumePosition.toInt()
|
||||||
@@ -100,7 +100,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.volume = volume
|
this.volume = volume
|
||||||
sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume))
|
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
override fun seekVideo(timeSeconds: Double) {
|
||||||
@@ -108,7 +108,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(Opcode.SEEK, FastCastSeekMessage(
|
sendMessage(Opcode.SEEK, FCastSeekMessage(
|
||||||
time = timeSeconds.toInt()
|
time = timeSeconds.toInt()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackUpdate = Json.decodeFromString<FastCastPlaybackUpdateMessage>(json);
|
val playbackUpdate = Json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
||||||
time = playbackUpdate.time.toDouble();
|
time = playbackUpdate.time.toDouble();
|
||||||
isPlaying = when (playbackUpdate.state) {
|
isPlaying = when (playbackUpdate.state) {
|
||||||
1 -> true
|
1 -> true
|
||||||
@@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
val volumeUpdate = Json.decodeFromString<FastCastVolumeUpdateMessage>(json);
|
val volumeUpdate = Json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||||
volume = volumeUpdate.volume;
|
volume = volumeUpdate.volume;
|
||||||
}
|
}
|
||||||
else -> { }
|
else -> { }
|
||||||
@@ -398,7 +398,7 @@ class FastCastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
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 {
|
companion object {
|
||||||
@@ -2,8 +2,11 @@ package com.futo.platformplayer.casting
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Base64
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.*
|
import com.futo.platformplayer.api.http.server.handlers.*
|
||||||
@@ -27,6 +30,9 @@ import javax.jmdns.ServiceListener
|
|||||||
import kotlin.collections.HashMap
|
import kotlin.collections.HashMap
|
||||||
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import javax.jmdns.ServiceTypeListener
|
import javax.jmdns.ServiceTypeListener
|
||||||
|
|
||||||
class StateCasting {
|
class StateCasting {
|
||||||
@@ -147,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() {
|
fun onStop() {
|
||||||
val ad = activeDevice ?: return;
|
val ad = activeDevice ?: return;
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||||
@@ -334,20 +366,25 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sourceCount > 1) {
|
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) {
|
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 {
|
} else {
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
try {
|
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);
|
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 {
|
} else {
|
||||||
|
Logger.i(TAG, "Casting as DASH indirect");
|
||||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -356,27 +393,35 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (videoSource is IVideoUrlSource)
|
if (videoSource is IVideoUrlSource) {
|
||||||
|
Logger.i(TAG, "Casting as singular video");
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||||
else if (audioSource is IAudioUrlSource)
|
} else if (audioSource is IAudioUrlSource) {
|
||||||
|
Logger.i(TAG, "Casting as singular audio");
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||||
else if(videoSource is IHLSManifestSource) {
|
} else if(videoSource is IHLSManifestSource) {
|
||||||
if (ad is ChromecastCastingDevice) {
|
if (ad is ChromecastCastingDevice) {
|
||||||
castHlsIndirect(video, videoSource.url, resumePosition);
|
Logger.i(TAG, "Casting as proxied HLS");
|
||||||
|
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition);
|
||||||
} else {
|
} 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());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||||
}
|
}
|
||||||
} else if(audioSource is IHLSManifestAudioSource) {
|
} else if(audioSource is IHLSManifestAudioSource) {
|
||||||
if (ad is ChromecastCastingDevice) {
|
if (ad is ChromecastCastingDevice) {
|
||||||
castHlsIndirect(video, audioSource.url, resumePosition);
|
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||||
|
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition);
|
||||||
} else {
|
} 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());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||||
}
|
}
|
||||||
} else if (videoSource is LocalVideoSource)
|
} else if (videoSource is LocalVideoSource) {
|
||||||
|
Logger.i(TAG, "Casting as local video");
|
||||||
castLocalVideo(video, videoSource, resumePosition);
|
castLocalVideo(video, videoSource, resumePosition);
|
||||||
else if (audioSource is LocalAudioSource)
|
} else if (audioSource is LocalAudioSource) {
|
||||||
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition);
|
castLocalAudio(video, audioSource, resumePosition);
|
||||||
else {
|
} else {
|
||||||
var str = listOf(
|
var str = listOf(
|
||||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||||
@@ -413,6 +458,14 @@ class StateCasting {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castVideoIndirect() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castAudioIndirect() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
@@ -421,7 +474,7 @@ class StateCasting {
|
|||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
val videoUrl = url + videoPath;
|
val videoUrl = url + videoPath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -440,7 +493,7 @@ class StateCasting {
|
|||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-${id}"
|
||||||
val audioUrl = url + audioPath;
|
val audioUrl = url + audioPath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -451,6 +504,101 @@ class StateCasting {
|
|||||||
return listOf(audioUrl);
|
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> {
|
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
@@ -468,43 +616,28 @@ class StateCasting {
|
|||||||
val audioUrl = url + audioPath;
|
val audioUrl = url + audioPath;
|
||||||
val subtitleUrl = url + subtitlePath;
|
val subtitleUrl = url + subtitlePath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
||||||
"application/dash+xml")
|
"application/dash+xml")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(videoPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(audioPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (subtitleSource != null) {
|
if (subtitleSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
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).");
|
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
|
||||||
@@ -538,7 +671,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -558,8 +691,8 @@ class StateCasting {
|
|||||||
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
|
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List<String> {
|
||||||
_castServer.removeAllHandlers("castHlsIndirectMaster")
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
@@ -569,31 +702,65 @@ class StateCasting {
|
|||||||
val hlsUrl = url + hlsPath
|
val hlsUrl = url + hlsPath
|
||||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||||
|
|
||||||
_castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
||||||
_castServer.removeAllHandlers("castHlsIndirectVariant")
|
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
||||||
|
|
||||||
val headers = masterContext.headers.clone()
|
val headers = masterContext.headers.clone()
|
||||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl)
|
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 newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
|
||||||
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
|
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||||
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.independentSegments)
|
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
|
||||||
|
|
||||||
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
|
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
|
||||||
val playlistId = UUID.randomUUID();
|
val playlistId = UUID.randomUUID();
|
||||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
val newPlaylistUrl = url + newPlaylistPath;
|
val newPlaylistUrl = url + newPlaylistPath;
|
||||||
|
|
||||||
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
|
val response = _client.get(variantPlaylistRef.url)
|
||||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
|
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()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
|
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||||
|
|
||||||
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
||||||
newPlaylistUrl,
|
newPlaylistUrl,
|
||||||
@@ -602,19 +769,29 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (mediaRendition in masterPlaylist.mediaRenditions) {
|
for (mediaRendition in masterPlaylist.mediaRenditions) {
|
||||||
val playlistId = UUID.randomUUID();
|
val playlistId = UUID.randomUUID()
|
||||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
|
||||||
val newPlaylistUrl = url + newPlaylistPath;
|
|
||||||
|
|
||||||
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
var newPlaylistUrl: String? = null
|
||||||
val vpHeaders = vpContext.headers.clone()
|
if (mediaRendition.uri != null) {
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
|
newPlaylistUrl = url + newPlaylistPath
|
||||||
|
|
||||||
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
|
val vpHeaders = vpContext.headers.clone()
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
|
||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
|
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(
|
newMediaRenditions.add(HLS.MediaRendition(
|
||||||
mediaRendition.type,
|
mediaRendition.type,
|
||||||
@@ -629,20 +806,27 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
|
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
|
||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster")
|
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
|
||||||
|
|
||||||
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
|
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
|
||||||
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
|
|
||||||
|
//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);
|
return listOf(hlsUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist): HLS.VariantPlaylist {
|
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||||
val newSegments = arrayListOf<HLS.Segment>()
|
val newSegments = arrayListOf<HLS.Segment>()
|
||||||
|
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
if (proxySegments) {
|
||||||
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||||
|
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newSegments.addAll(variantPlaylist.segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
return HLS.VariantPlaylist(
|
return HLS.VariantPlaylist(
|
||||||
@@ -651,35 +835,167 @@ class StateCasting {
|
|||||||
variantPlaylist.mediaSequence,
|
variantPlaylist.mediaSequence,
|
||||||
variantPlaylist.discontinuitySequence,
|
variantPlaylist.discontinuitySequence,
|
||||||
variantPlaylist.programDateTime,
|
variantPlaylist.programDateTime,
|
||||||
|
variantPlaylist.playlistType,
|
||||||
|
variantPlaylist.streamInfo,
|
||||||
newSegments
|
newSegments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
if (segment is HLS.MediaSegment) {
|
||||||
val newSegmentUrl = url + newSegmentPath;
|
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||||
|
val newSegmentUrl = url + newSegmentPath;
|
||||||
|
|
||||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
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()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castHlsIndirectVariant")
|
).withTag("castHlsIndirectVariant");
|
||||||
}
|
}
|
||||||
|
|
||||||
return HLS.Segment(
|
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||||
segment.duration,
|
return@withContext subtitleSource.getSubtitlesURI();
|
||||||
newSegmentUrl
|
} 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> {
|
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = ad !is FastCastCastingDevice;
|
val proxyStreams = ad !is FCastCastingDevice;
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
Logger.i(TAG, "DASH url: $url");
|
|
||||||
|
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@@ -688,6 +1004,8 @@ class StateCasting {
|
|||||||
val subtitlePath = "/subtitle-${id}"
|
val subtitlePath = "/subtitle-${id}"
|
||||||
|
|
||||||
val dashUrl = url + dashPath;
|
val dashUrl = url + dashPath;
|
||||||
|
Logger.i(TAG, "DASH url: $dashUrl");
|
||||||
|
|
||||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
||||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
||||||
|
|
||||||
@@ -709,7 +1027,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -721,38 +1039,29 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
||||||
"application/dash+xml")
|
"application/dash+xml")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
|
|
||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(videoPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).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).");
|
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());
|
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||||
}
|
}
|
||||||
@@ -765,8 +1074,8 @@ class StateCasting {
|
|||||||
CastProtocolType.AIRPLAY -> {
|
CastProtocolType.AIRPLAY -> {
|
||||||
AirPlayCastingDevice(deviceInfo);
|
AirPlayCastingDevice(deviceInfo);
|
||||||
}
|
}
|
||||||
CastProtocolType.FASTCAST -> {
|
CastProtocolType.FCAST -> {
|
||||||
FastCastCastingDevice(deviceInfo);
|
FCastCastingDevice(deviceInfo);
|
||||||
}
|
}
|
||||||
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
|
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
|
||||||
}
|
}
|
||||||
@@ -813,8 +1122,8 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||||
return addOrUpdateCastDevice<FastCastCastingDevice>(name,
|
return addOrUpdateCastDevice<FCastCastingDevice>(name,
|
||||||
deviceFactory = { FastCastCastingDevice(name, addresses, port) },
|
deviceFactory = { FCastCastingDevice(name, addresses, port) },
|
||||||
deviceUpdater = { d ->
|
deviceUpdater = { d ->
|
||||||
if (d.isReady) {
|
if (d.isReady) {
|
||||||
return@addOrUpdateCastDevice false;
|
return@addOrUpdateCastDevice false;
|
||||||
@@ -890,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 {
|
companion object {
|
||||||
val instance: StateCasting = StateCasting();
|
val instance: StateCasting = StateCasting();
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -3,7 +3,7 @@ package com.futo.platformplayer.casting.models
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class FastCastPlayMessage(
|
data class FCastPlayMessage(
|
||||||
val container: String,
|
val container: String,
|
||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
val content: String? = null,
|
val content: String? = null,
|
||||||
@@ -11,23 +11,23 @@ data class FastCastPlayMessage(
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class FastCastSeekMessage(
|
data class FCastSeekMessage(
|
||||||
val time: Int
|
val time: Int
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class FastCastPlaybackUpdateMessage(
|
data class FCastPlaybackUpdateMessage(
|
||||||
val time: Int,
|
val time: Int,
|
||||||
val state: Int
|
val state: Int
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class FastCastVolumeUpdateMessage(
|
data class FCastVolumeUpdateMessage(
|
||||||
val volume: Double
|
val volume: Double
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class FastCastSetVolumeMessage(
|
data class FCastSetVolumeMessage(
|
||||||
val volume: Double
|
val volume: Double
|
||||||
)
|
)
|
||||||
@@ -24,6 +24,7 @@ import com.google.gson.JsonArray
|
|||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.lang.reflect.InvocationTargetException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.reflect.jvm.jvmErasure
|
import kotlin.reflect.jvm.jvmErasure
|
||||||
|
|
||||||
@@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
val config = context.readContentJson<SourcePluginConfig>()
|
val config = context.readContentJson<SourcePluginConfig>()
|
||||||
try {
|
try {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config);
|
|
||||||
|
val client = JSHttpClient(null, null, null, config);
|
||||||
|
val clientAuth = JSHttpClient(null, null, null, config);
|
||||||
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
|
||||||
|
|
||||||
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
}
|
}
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it));
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
|
||||||
};
|
};
|
||||||
context.respondCode(200, "Login started");
|
context.respondCode(200, "Login started");
|
||||||
@@ -311,6 +316,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
val json = wrapRemoteResult(callResult, false);
|
val json = wrapRemoteResult(callResult, false);
|
||||||
context.respondCode(200, json, "application/json");
|
context.respondCode(200, json, "application/json");
|
||||||
}
|
}
|
||||||
|
catch(invocation: InvocationTargetException) {
|
||||||
|
val innerException = invocation.targetException;
|
||||||
|
Logger.e("DeveloperEndpoints", innerException.message, innerException);
|
||||||
|
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
|
||||||
|
}
|
||||||
catch(ilEx: IllegalArgumentException) {
|
catch(ilEx: IllegalArgumentException) {
|
||||||
if(ilEx.message?.contains("does not exist") ?: false) {
|
if(ilEx.message?.contains("does not exist") ?: false) {
|
||||||
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import com.futo.platformplayer.UIDialogs
|
|||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||||
@@ -26,6 +23,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private lateinit var _textError: TextView;
|
private lateinit var _textError: TextView;
|
||||||
private lateinit var _buttonCancel: Button;
|
private lateinit var _buttonCancel: Button;
|
||||||
private lateinit var _buttonConfirm: LinearLayout;
|
private lateinit var _buttonConfirm: LinearLayout;
|
||||||
|
private lateinit var _buttonTutorial: TextView;
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -38,6 +36,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_textError = findViewById(R.id.text_error);
|
_textError = findViewById(R.id.text_error);
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel);
|
||||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
_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 ->
|
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
@@ -62,7 +61,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
_buttonConfirm.setOnClickListener {
|
_buttonConfirm.setOnClickListener {
|
||||||
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
|
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
|
||||||
0 -> CastProtocolType.FASTCAST
|
0 -> CastProtocolType.FCAST
|
||||||
1 -> CastProtocolType.CHROMECAST
|
1 -> CastProtocolType.CHROMECAST
|
||||||
2 -> CastProtocolType.AIRPLAY
|
2 -> CastProtocolType.AIRPLAY
|
||||||
else -> {
|
else -> {
|
||||||
@@ -105,6 +104,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
||||||
performDismiss();
|
performDismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_buttonTutorial.setOnClickListener {
|
||||||
|
UIDialogs.showCastingTutorialDialog(context)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun show() {
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
|||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@@ -97,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers()
|
processHandle.fullyBackfillServersAnnounceExceptions()
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||||
@@ -117,7 +118,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
|||||||
msg = comment,
|
msg = comment,
|
||||||
rating = RatingLikeDislikes(0, 0),
|
rating = RatingLikeDislikes(0, 0),
|
||||||
date = OffsetDateTime.now(),
|
date = OffsetDateTime.now(),
|
||||||
reference = eventPointer.toReference()
|
eventPointer = eventPointer
|
||||||
));
|
));
|
||||||
|
|
||||||
dismiss();
|
dismiss();
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
package com.futo.platformplayer.dialogs
|
package com.futo.platformplayer.dialogs
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.AddSourceActivity
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.activities.QRCaptureActivity
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastingDevice
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private lateinit var _imageLoader: ImageView;
|
private lateinit var _imageLoader: ImageView;
|
||||||
private lateinit var _buttonClose: Button;
|
private lateinit var _buttonClose: Button;
|
||||||
private lateinit var _buttonAdd: Button;
|
private lateinit var _buttonAdd: Button;
|
||||||
|
private lateinit var _buttonScanQR: Button;
|
||||||
private lateinit var _textNoDevicesFound: TextView;
|
private lateinit var _textNoDevicesFound: TextView;
|
||||||
private lateinit var _textNoDevicesRemembered: TextView;
|
private lateinit var _textNoDevicesRemembered: TextView;
|
||||||
private lateinit var _recyclerDevices: RecyclerView;
|
private lateinit var _recyclerDevices: RecyclerView;
|
||||||
@@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_imageLoader = findViewById(R.id.image_loader);
|
_imageLoader = findViewById(R.id.image_loader);
|
||||||
_buttonClose = findViewById(R.id.button_close);
|
_buttonClose = findViewById(R.id.button_close);
|
||||||
_buttonAdd = findViewById(R.id.button_add);
|
_buttonAdd = findViewById(R.id.button_add);
|
||||||
|
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
||||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
||||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||||
@@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
UIDialogs.showCastingAddDialog(context);
|
UIDialogs.showCastingAddDialog(context);
|
||||||
dismiss();
|
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() {
|
override fun show() {
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ import com.futo.platformplayer.casting.*
|
|||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.google.android.material.slider.Slider
|
import com.google.android.material.slider.Slider
|
||||||
import com.google.android.material.slider.Slider.OnChangeListener
|
import com.google.android.material.slider.Slider.OnChangeListener
|
||||||
import com.google.android.material.slider.Slider.OnSliderTouchListener
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||||
@@ -105,7 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
} else if (d is AirPlayCastingDevice) {
|
} else if (d is AirPlayCastingDevice) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
} else if (d is FastCastCastingDevice) {
|
} else if (d is FCastCastingDevice) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||||
_textType.text = "FastCast";
|
_textType.text = "FastCast";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
package com.futo.platformplayer.downloads
|
package com.futo.platformplayer.downloads
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -18,22 +24,28 @@ import com.futo.platformplayer.hasAnySource
|
|||||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.isDownloadable
|
import com.futo.platformplayer.isDownloadable
|
||||||
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
import com.futo.platformplayer.toHumanBytesSpeed
|
import com.futo.platformplayer.toHumanBytesSpeed
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
class VideoDownload {
|
class VideoDownload {
|
||||||
@@ -137,7 +149,7 @@ class VideoDownload {
|
|||||||
return items.joinToString(" • ");
|
return items.joinToString(" • ");
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun prepare() {
|
suspend fun prepare(client: ManagedHttpClient) {
|
||||||
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
||||||
if(video == null && videoDetails == null)
|
if(video == null && videoDetails == null)
|
||||||
throw IllegalStateException("Missing information for download to complete");
|
throw IllegalStateException("Missing information for download to complete");
|
||||||
@@ -157,24 +169,65 @@ class VideoDownload {
|
|||||||
|
|
||||||
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
|
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
|
||||||
if(videoSource == null && targetPixelCount != null) {
|
if(videoSource == null && targetPixelCount != null) {
|
||||||
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
|
val videoSources = arrayListOf<IVideoSource>()
|
||||||
|
for (source in original.video.videoSources) {
|
||||||
|
if (source is IHLSManifestSource) {
|
||||||
|
try {
|
||||||
|
val playlistResponse = client.get(source.url)
|
||||||
|
if (playlistResponse.isOk) {
|
||||||
|
val playlistContent = playlistResponse.body?.string()
|
||||||
|
if (playlistContent != null) {
|
||||||
|
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.i(TAG, "Failed to get HLS video sources", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoSources.add(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
|
||||||
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
||||||
if(vsource != null) {
|
if(vsource != null) {
|
||||||
if (vsource is IVideoUrlSource)
|
if (vsource is IVideoUrlSource)
|
||||||
videoSource = VideoUrlSource.fromUrlSource(vsource);
|
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||||
else
|
else
|
||||||
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(audioSource == null && targetBitrate != null) {
|
if(audioSource == null && targetBitrate != null) {
|
||||||
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
|
val audioSources = arrayListOf<IAudioSource>()
|
||||||
|
val video = original.video
|
||||||
|
if (video is VideoUnMuxedSourceDescriptor) {
|
||||||
|
for (source in video.audioSources) {
|
||||||
|
if (source is IHLSManifestSource) {
|
||||||
|
try {
|
||||||
|
val playlistResponse = client.get(source.url)
|
||||||
|
if (playlistResponse.isOk) {
|
||||||
|
val playlistContent = playlistResponse.body?.string()
|
||||||
|
if (playlistContent != null) {
|
||||||
|
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.i(TAG, "Failed to get HLS audio sources", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audioSources.add(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
|
||||||
?: if(videoSource != null ) null
|
?: if(videoSource != null ) null
|
||||||
else throw DownloadException("Could not find a valid video or audio source for download")
|
else throw DownloadException("Could not find a valid video or audio source for download")
|
||||||
if(asource == null)
|
if(asource == null)
|
||||||
audioSource = null;
|
audioSource = null;
|
||||||
else if(asource is IAudioUrlSource)
|
else if(asource is IAudioUrlSource)
|
||||||
audioSource = AudioUrlSource.fromUrlSource(asource);
|
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||||
else
|
else
|
||||||
throw DownloadException("Audio source is not supported for downloading (yet)", false);
|
throw DownloadException("Audio source is not supported for downloading (yet)", false);
|
||||||
}
|
}
|
||||||
@@ -183,7 +236,8 @@ class VideoDownload {
|
|||||||
throw DownloadException("No valid sources found for video/audio");
|
throw DownloadException("No valid sources found for video/audio");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
|
||||||
|
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
||||||
Logger.i(TAG, "VideoDownload Download [${name}]");
|
Logger.i(TAG, "VideoDownload Download [${name}]");
|
||||||
if(videoDetails == null || (videoSource == null && audioSource == null))
|
if(videoDetails == null || (videoSource == null && audioSource == null))
|
||||||
throw IllegalStateException("Missing information for download to complete");
|
throw IllegalStateException("Missing information for download to complete");
|
||||||
@@ -199,7 +253,7 @@ class VideoDownload {
|
|||||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
||||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(subtitleSource != null) {
|
if(subtitleSource != null) {
|
||||||
@@ -217,7 +271,8 @@ class VideoDownload {
|
|||||||
if(videoSource != null) {
|
if(videoSource != null) {
|
||||||
sourcesToDownload.add(async {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading video");
|
Logger.i(TAG, "Started downloading video");
|
||||||
videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed ->
|
|
||||||
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastVideoLength = length;
|
lastVideoLength = length;
|
||||||
lastVideoRead = totalRead;
|
lastVideoRead = totalRead;
|
||||||
@@ -235,12 +290,18 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoFileSize = when (videoSource!!.container) {
|
||||||
|
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
|
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
sourcesToDownload.add(async {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading audio");
|
Logger.i(TAG, "Started downloading audio");
|
||||||
audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed ->
|
|
||||||
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastAudioLength = length;
|
lastAudioLength = length;
|
||||||
lastAudioRead = totalRead;
|
lastAudioRead = totalRead;
|
||||||
@@ -258,6 +319,11 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioFileSize = when (audioSource!!.container) {
|
||||||
|
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
|
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (subtitleSource != null) {
|
if (subtitleSource != null) {
|
||||||
@@ -279,7 +345,105 @@ class VideoDownload {
|
|||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
|
||||||
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
|
if(targetFile.exists())
|
||||||
|
targetFile.delete();
|
||||||
|
|
||||||
|
var downloadedTotalLength = 0L
|
||||||
|
|
||||||
|
val segmentFiles = arrayListOf<File>()
|
||||||
|
try {
|
||||||
|
val response = client.get(hlsUrl)
|
||||||
|
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, hlsUrl)
|
||||||
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment !is HLS.MediaSegment) {
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
|
|
||||||
|
val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||||
|
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||||
|
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||||
|
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedTotalLength += segmentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Combining segments into $targetFile");
|
||||||
|
combineSegments(context, segmentFiles, targetFile)
|
||||||
|
|
||||||
|
Logger.i(TAG, "${name} downloadSource Finished");
|
||||||
|
}
|
||||||
|
catch(ioex: IOException) {
|
||||||
|
if(targetFile.exists() ?: false)
|
||||||
|
targetFile.delete();
|
||||||
|
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||||
|
throw Exception("Not enough space on device", ioex);
|
||||||
|
else
|
||||||
|
throw ioex;
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
if(targetFile.exists() ?: false)
|
||||||
|
targetFile.delete();
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
for (segmentFile in segmentFiles) {
|
||||||
|
segmentFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downloadedTotalLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||||
|
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||||
|
|
||||||
|
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||||
|
|
||||||
|
val statisticsCallback = StatisticsCallback { statistics ->
|
||||||
|
//TODO: Show progress?
|
||||||
|
}
|
||||||
|
|
||||||
|
val executorService = Executors.newSingleThreadExecutor()
|
||||||
|
val session = FFmpegKit.executeAsync(cmd,
|
||||||
|
{ session ->
|
||||||
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
fileList.delete()
|
||||||
|
continuation.resumeWith(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||||
|
"Command cancelled"
|
||||||
|
} else {
|
||||||
|
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||||
|
}
|
||||||
|
fileList.delete()
|
||||||
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ Logger.v(TAG, it.message) },
|
||||||
|
statisticsCallback,
|
||||||
|
executorService
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
session.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
|
||||||
@@ -472,8 +636,10 @@ class VideoDownload {
|
|||||||
val expectedFile = File(videoFilePath!!);
|
val expectedFile = File(videoFilePath!!);
|
||||||
if(!expectedFile.exists())
|
if(!expectedFile.exists())
|
||||||
throw IllegalStateException("Video file missing after download");
|
throw IllegalStateException("Video file missing after download");
|
||||||
if(expectedFile.length() != videoFileSize)
|
if (videoSource?.container != "application/vnd.apple.mpegurl") {
|
||||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
if (expectedFile.length() != videoFileSize)
|
||||||
|
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
if(audioFilePath == null)
|
if(audioFilePath == null)
|
||||||
@@ -481,8 +647,10 @@ class VideoDownload {
|
|||||||
val expectedFile = File(audioFilePath!!);
|
val expectedFile = File(audioFilePath!!);
|
||||||
if(!expectedFile.exists())
|
if(!expectedFile.exists())
|
||||||
throw IllegalStateException("Audio file missing after download");
|
throw IllegalStateException("Audio file missing after download");
|
||||||
if(expectedFile.length() != audioFileSize)
|
if (audioSource?.container != "application/vnd.apple.mpegurl") {
|
||||||
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
|
if (expectedFile.length() != audioFileSize)
|
||||||
|
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(subtitleSource != null) {
|
if(subtitleSource != null) {
|
||||||
if(subtitleFilePath == null)
|
if(subtitleFilePath == null)
|
||||||
@@ -560,7 +728,7 @@ class VideoDownload {
|
|||||||
const val GROUP_PLAYLIST = "Playlist";
|
const val GROUP_PLAYLIST = "Playlist";
|
||||||
|
|
||||||
fun videoContainerToExtension(container: String): String? {
|
fun videoContainerToExtension(container: String): String? {
|
||||||
if (container.contains("video/mp4"))
|
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||||
return "mp4";
|
return "mp4";
|
||||||
else if (container.contains("application/x-mpegURL"))
|
else if (container.contains("application/x-mpegURL"))
|
||||||
return "m3u8";
|
return "m3u8";
|
||||||
@@ -585,6 +753,8 @@ class VideoDownload {
|
|||||||
return "mp3";
|
return "mp3";
|
||||||
else if (container.contains("audio/webm"))
|
else if (container.contains("audio/webm"))
|
||||||
return "webma";
|
return "webma";
|
||||||
|
else if (container == "application/vnd.apple.mpegurl")
|
||||||
|
return "mp4";
|
||||||
else
|
else
|
||||||
return "audio";
|
return "audio";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.internal.V8BindObject
|
|||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
|
import kotlin.streams.asSequence
|
||||||
import kotlin.streams.toList
|
import kotlin.streams.toList
|
||||||
|
|
||||||
class PackageHttp: V8Package {
|
class PackageHttp: V8Package {
|
||||||
@@ -171,7 +172,9 @@ class PackageHttp: V8Package {
|
|||||||
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers);
|
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers);
|
||||||
else
|
else
|
||||||
return@map it.first.request(it.second.method, it.second.url, it.second.headers);
|
return@map it.first.request(it.second.method, it.second.url, it.second.headers);
|
||||||
}.toList();
|
}
|
||||||
|
.asSequence()
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@@ -23,7 +24,6 @@ import com.futo.platformplayer.api.media.structures.IPager
|
|||||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
@@ -31,6 +31,7 @@ import com.futo.platformplayer.engine.exceptions.PluginException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
@@ -58,6 +59,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
val onAddToClicked = Event1<IPlatformContent>();
|
val onAddToClicked = Event1<IPlatformContent>();
|
||||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||||
|
val onLongPress = Event1<IPlatformContent>();
|
||||||
|
|
||||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||||
Logger.i(TAG, "getContentPager");
|
Logger.i(TAG, "getContentPager");
|
||||||
@@ -76,7 +78,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
||||||
val livePager = getContentPager(it);
|
val livePager = getContentPager(it);
|
||||||
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
||||||
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
|
StateCache.cachePagerResults(lifecycleScope, livePager);
|
||||||
else livePager;
|
else livePager;
|
||||||
}).success { livePager ->
|
}).success { livePager ->
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -104,7 +106,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val posBefore = _results.size;
|
val posBefore = _results.size;
|
||||||
val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo };
|
val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }
|
||||||
_results.addAll(toAdd);
|
_results.addAll(toAdd);
|
||||||
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); };
|
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); };
|
||||||
}.exception<Throwable> {
|
}.exception<Throwable> {
|
||||||
@@ -151,13 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
|
|
||||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
||||||
|
|
||||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply {
|
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
|
||||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||||
|
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
_llmVideo = LinearLayoutManager(view.context);
|
_llmVideo = LinearLayoutManager(view.context);
|
||||||
|
|||||||
+1
@@ -351,6 +351,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
||||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
||||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
||||||
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
|
||||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class BuyFragment : MainFragment() {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroyMainView() {
|
||||||
|
super.onDestroyMainView()
|
||||||
|
_view = null
|
||||||
|
}
|
||||||
|
|
||||||
class BuyView: LinearLayout {
|
class BuyView: LinearLayout {
|
||||||
private val _fragment: BuyFragment;
|
private val _fragment: BuyFragment;
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -223,6 +223,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
else -> {};
|
else -> {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
adapter.onLongPress.subscribe { content ->
|
||||||
|
_overlayContainer.let {
|
||||||
|
if(content is IPlatformVideo)
|
||||||
|
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||||
|
}
|
||||||
|
}
|
||||||
viewPager.adapter = adapter;
|
viewPager.adapter = adapter;
|
||||||
|
|
||||||
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
||||||
@@ -431,11 +437,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
|
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
|
||||||
|
setPolycentricProfile(null, animate = false);
|
||||||
|
|
||||||
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
|
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
|
||||||
if (cachedProfile != null) {
|
if (cachedProfile != null) {
|
||||||
setPolycentricProfile(cachedProfile, animate = false);
|
setPolycentricProfile(cachedProfile, animate = false);
|
||||||
} else {
|
} else {
|
||||||
setPolycentricProfile(null, animate = false);
|
|
||||||
or();
|
or();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewPropertyAnimator
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.Spinner
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
|
||||||
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
|
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||||
|
import com.futo.polycentric.core.PublicKey
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.util.IdentityHashMap
|
||||||
|
|
||||||
|
class CommentsFragment : MainFragment() {
|
||||||
|
override val isMainView : Boolean = true
|
||||||
|
override val isTab: Boolean = true
|
||||||
|
override val hasBottomBar: Boolean get() = true
|
||||||
|
|
||||||
|
private var _view: CommentsView? = null
|
||||||
|
|
||||||
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
|
super.onShownWithView(parameter, isBack)
|
||||||
|
_view?.onShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
val view = CommentsView(this, inflater)
|
||||||
|
_view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyMainView() {
|
||||||
|
super.onDestroyMainView()
|
||||||
|
_view = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed(): Boolean {
|
||||||
|
return _view?.onBackPressed() ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
_view?.onShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = CommentsFragment().apply {}
|
||||||
|
private const val TAG = "CommentsFragment"
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommentsView : FrameLayout {
|
||||||
|
private val _fragment: CommentsFragment
|
||||||
|
private val _recyclerComments: RecyclerView;
|
||||||
|
private val _adapterComments: InsertedViewAdapterWithLoader<CommentWithReferenceViewHolder>;
|
||||||
|
private val _textCommentCount: TextView
|
||||||
|
private val _comments: ArrayList<IPlatformComment> = arrayListOf();
|
||||||
|
private val _llmReplies: LinearLayoutManager;
|
||||||
|
private val _spinnerSortBy: Spinner;
|
||||||
|
private val _layoutNotLoggedIn: LinearLayout;
|
||||||
|
private val _layoutPolycentricNotEnabled: LinearLayout;
|
||||||
|
private val _buttonLogin: LinearLayout;
|
||||||
|
private var _loading = false;
|
||||||
|
private val _repliesOverlay: RepliesOverlay;
|
||||||
|
private var _repliesAnimator: ViewPropertyAnimator? = null;
|
||||||
|
private val _cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies> = IdentityHashMap()
|
||||||
|
|
||||||
|
private val _taskLoadComments = if(!isInEditMode) TaskHandler<PublicKey, List<IPlatformComment>>(
|
||||||
|
StateApp.instance.scopeGetter, { StatePolycentric.instance.getSystemComments(context, it) })
|
||||||
|
.success { pager -> onCommentsLoaded(pager); }
|
||||||
|
.exception<UnknownHostException> {
|
||||||
|
UIDialogs.toast("Failed to load comments");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
.exception<Throwable> {
|
||||||
|
Logger.e(TAG, "Failed to load comments.", it);
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
|
||||||
|
setLoading(false);
|
||||||
|
} else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
|
||||||
|
|
||||||
|
constructor(fragment: CommentsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||||
|
_fragment = fragment
|
||||||
|
inflater.inflate(R.layout.fragment_comments, this)
|
||||||
|
|
||||||
|
val commentHeader = findViewById<LinearLayout>(R.id.layout_header)
|
||||||
|
(commentHeader.parent as ViewGroup).removeView(commentHeader)
|
||||||
|
_textCommentCount = commentHeader.findViewById(R.id.text_comment_count)
|
||||||
|
|
||||||
|
_recyclerComments = findViewById(R.id.recycler_comments)
|
||||||
|
_adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(commentHeader), arrayListOf(),
|
||||||
|
childCountGetter = { _comments.size },
|
||||||
|
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position]); },
|
||||||
|
childViewHolderFactory = { viewGroup, _ ->
|
||||||
|
val holder = CommentWithReferenceViewHolder(viewGroup, _cache);
|
||||||
|
holder.onDelete.subscribe(::onDelete);
|
||||||
|
holder.onRepliesClick.subscribe(::onRepliesClick);
|
||||||
|
return@InsertedViewAdapterWithLoader holder;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
_spinnerSortBy = commentHeader.findViewById(R.id.spinner_sortby);
|
||||||
|
_spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.comments_sortby_array)).also {
|
||||||
|
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
|
};
|
||||||
|
_spinnerSortBy.setSelection(0);
|
||||||
|
_spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||||
|
if (_spinnerSortBy.selectedItemPosition == 0) {
|
||||||
|
_comments.sortByDescending { it.date!! }
|
||||||
|
} else if (_spinnerSortBy.selectedItemPosition == 1) {
|
||||||
|
_comments.sortBy { it.date!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
_adapterComments.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
_llmReplies = LinearLayoutManager(context);
|
||||||
|
_recyclerComments.layoutManager = _llmReplies;
|
||||||
|
_recyclerComments.adapter = _adapterComments;
|
||||||
|
updateCommentCountString();
|
||||||
|
|
||||||
|
_layoutNotLoggedIn = findViewById(R.id.layout_not_logged_in)
|
||||||
|
_layoutNotLoggedIn.visibility = View.GONE
|
||||||
|
|
||||||
|
_layoutPolycentricNotEnabled = findViewById(R.id.layout_polycentric_disabled)
|
||||||
|
_layoutPolycentricNotEnabled.visibility = if (!StatePolycentric.instance.enabled) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
_buttonLogin = findViewById(R.id.button_login)
|
||||||
|
_buttonLogin.setOnClickListener {
|
||||||
|
context.startActivity(Intent(context, PolycentricHomeActivity::class.java));
|
||||||
|
}
|
||||||
|
|
||||||
|
_repliesOverlay = findViewById(R.id.replies_overlay);
|
||||||
|
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDelete(comment: IPlatformComment) {
|
||||||
|
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete this comment?", {
|
||||||
|
val processHandle = StatePolycentric.instance.processHandle ?: return@showConfirmationDialog
|
||||||
|
if (comment !is PolycentricPlatformComment) {
|
||||||
|
return@showConfirmationDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
val index = _comments.indexOf(comment)
|
||||||
|
if (index != -1) {
|
||||||
|
_comments.removeAt(index)
|
||||||
|
_adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
processHandle.delete(comment.eventPointer.process, comment.eventPointer.logicalClock)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to delete event.", e);
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Started backfill");
|
||||||
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
|
Logger.i(TAG, "Finished backfill");
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to fully backfill servers.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackPressed(): Boolean {
|
||||||
|
if (_repliesOverlay.visibility == View.VISIBLE) {
|
||||||
|
setRepliesOverlayVisible(isVisible = false, animate = true);
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onRepliesClick(c: IPlatformComment) {
|
||||||
|
val replyCount = c.replyCount ?: 0;
|
||||||
|
var metadata = "";
|
||||||
|
if (replyCount > 0) {
|
||||||
|
metadata += "$replyCount " + context.getString(R.string.replies);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c is PolycentricPlatformComment) {
|
||||||
|
_repliesOverlay.load(false, metadata, c.contextUrl, c.reference, c,
|
||||||
|
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||||
|
{ newComment ->
|
||||||
|
synchronized(_cache) {
|
||||||
|
_cache.remove(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newCommentIndex = if (_spinnerSortBy.selectedItemPosition == 0) {
|
||||||
|
_comments.indexOfFirst { it.date!! < newComment.date!! }.takeIf { it != -1 } ?: _comments.size
|
||||||
|
} else {
|
||||||
|
_comments.indexOfFirst { it.date!! > newComment.date!! }.takeIf { it != -1 } ?: _comments.size
|
||||||
|
}
|
||||||
|
|
||||||
|
_comments.add(newCommentIndex, newComment)
|
||||||
|
_adapterComments.notifyItemInserted(_adapterComments.childToParentPosition(newCommentIndex))
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_repliesOverlay.load(true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRepliesOverlayVisible(isVisible = true, animate = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
|
||||||
|
val desiredVisibility = if (isVisible) View.VISIBLE else View.GONE
|
||||||
|
if (_repliesOverlay.visibility == desiredVisibility) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_repliesAnimator?.cancel();
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
_repliesOverlay.visibility = View.VISIBLE;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
|
||||||
|
|
||||||
|
_repliesAnimator = _repliesOverlay.animate()
|
||||||
|
.setDuration(300)
|
||||||
|
.translationY(0f)
|
||||||
|
.withEndAction {
|
||||||
|
_repliesAnimator = null;
|
||||||
|
}.apply { start() };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (animate) {
|
||||||
|
_repliesOverlay.translationY = 0f;
|
||||||
|
|
||||||
|
_repliesAnimator = _repliesOverlay.animate()
|
||||||
|
.setDuration(300)
|
||||||
|
.translationY(_repliesOverlay.height.toFloat())
|
||||||
|
.withEndAction {
|
||||||
|
_repliesOverlay.visibility = GONE;
|
||||||
|
_repliesAnimator = null;
|
||||||
|
}.apply { start(); }
|
||||||
|
} else {
|
||||||
|
_repliesOverlay.visibility = View.GONE;
|
||||||
|
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCommentCountString() {
|
||||||
|
_textCommentCount.text = context.getString(R.string.these_are_all_commentcount_comments_you_have_made_in_grayjay).replace("{commentCount}", _comments.size.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLoading(loading: Boolean) {
|
||||||
|
if (_loading == loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = loading;
|
||||||
|
_adapterComments.setLoading(loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchComments() {
|
||||||
|
val system = StatePolycentric.instance.processHandle?.system ?: return
|
||||||
|
_comments.clear()
|
||||||
|
_adapterComments.notifyDataSetChanged()
|
||||||
|
setLoading(true)
|
||||||
|
_taskLoadComments.run(system)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCommentsLoaded(comments: List<IPlatformComment>) {
|
||||||
|
setLoading(false)
|
||||||
|
_comments.addAll(comments)
|
||||||
|
|
||||||
|
if (_spinnerSortBy.selectedItemPosition == 0) {
|
||||||
|
_comments.sortByDescending { it.date!! }
|
||||||
|
} else if (_spinnerSortBy.selectedItemPosition == 1) {
|
||||||
|
_comments.sortBy { it.date!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
_adapterComments.notifyDataSetChanged()
|
||||||
|
updateCommentCountString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShown() {
|
||||||
|
_layoutPolycentricNotEnabled.visibility = if (!StatePolycentric.instance.enabled) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val processHandle = StatePolycentric.instance.processHandle
|
||||||
|
if (processHandle != null) {
|
||||||
|
_layoutNotLoggedIn.visibility = View.GONE
|
||||||
|
_recyclerComments.visibility = View.VISIBLE
|
||||||
|
fetchComments()
|
||||||
|
} else {
|
||||||
|
_layoutNotLoggedIn.visibility = View.VISIBLE
|
||||||
|
_recyclerComments.visibility= View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -37,6 +37,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
||||||
protected lateinit var headerView: LinearLayout;
|
protected lateinit var headerView: LinearLayout;
|
||||||
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
|
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
|
||||||
|
protected open val shouldShowTimeBar: Boolean get() = true
|
||||||
|
|
||||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
};
|
};
|
||||||
headerView = v;
|
headerView = v;
|
||||||
|
|
||||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply {
|
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
|
||||||
attachAdapterEvents(this);
|
attachAdapterEvents(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -84,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
private var _channelUrl: String? = null;
|
private var _channelUrl: String? = null;
|
||||||
|
|
||||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||||
|
|
||||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||||
|
|||||||
+12
@@ -6,8 +6,10 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -21,9 +23,13 @@ class CreatorsFragment : MainFragment() {
|
|||||||
|
|
||||||
private var _spinnerSortBy: Spinner? = null;
|
private var _spinnerSortBy: Spinner? = null;
|
||||||
private var _overlayContainer: FrameLayout? = null;
|
private var _overlayContainer: FrameLayout? = null;
|
||||||
|
private var _containerSearch: FrameLayout? = null;
|
||||||
|
private var _editSearch: EditText? = null;
|
||||||
|
|
||||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val view = inflater.inflate(R.layout.fragment_creators, container, false);
|
val view = inflater.inflate(R.layout.fragment_creators, container, false);
|
||||||
|
_containerSearch = view.findViewById(R.id.container_search);
|
||||||
|
_editSearch = view.findViewById(R.id.edit_search);
|
||||||
|
|
||||||
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
|
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
|
||||||
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
|
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
|
||||||
@@ -44,6 +50,10 @@ class CreatorsFragment : MainFragment() {
|
|||||||
|
|
||||||
_spinnerSortBy = spinnerSortBy;
|
_spinnerSortBy = spinnerSortBy;
|
||||||
|
|
||||||
|
_editSearch?.addTextChangedListener {
|
||||||
|
adapter.query = it.toString();
|
||||||
|
}
|
||||||
|
|
||||||
val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions);
|
val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions);
|
||||||
recyclerView.adapter = adapter;
|
recyclerView.adapter = adapter;
|
||||||
recyclerView.layoutManager = LinearLayoutManager(view.context);
|
recyclerView.layoutManager = LinearLayoutManager(view.context);
|
||||||
@@ -54,6 +64,8 @@ class CreatorsFragment : MainFragment() {
|
|||||||
super.onDestroyMainView();
|
super.onDestroyMainView();
|
||||||
_spinnerSortBy = null;
|
_spinnerSortBy = null;
|
||||||
_overlayContainer = null;
|
_overlayContainer = null;
|
||||||
|
_editSearch = null;
|
||||||
|
_containerSearch = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
|||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
|
||||||
import com.futo.platformplayer.api.media.structures.*
|
import com.futo.platformplayer.api.media.structures.*
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -21,7 +20,6 @@ import com.futo.platformplayer.constructs.TaskHandler
|
|||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.others.TagsView
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
@@ -64,6 +62,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
val fragment: TFragment;
|
val fragment: TFragment;
|
||||||
|
|
||||||
private val _scrollListener: RecyclerView.OnScrollListener;
|
private val _scrollListener: RecyclerView.OnScrollListener;
|
||||||
|
private var _automaticNextPageCounter = 0;
|
||||||
|
|
||||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
||||||
this.fragment = fragment;
|
this.fragment = fragment;
|
||||||
@@ -122,7 +121,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||||
|
|
||||||
var filteredNextPageCounter = 0;
|
|
||||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||||
if (it is IAsyncPager<*>)
|
if (it is IAsyncPager<*>)
|
||||||
it.nextPageAsync();
|
it.nextPageAsync();
|
||||||
@@ -142,15 +140,8 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
val filteredResults = filterResults(it);
|
val filteredResults = filterResults(it);
|
||||||
recyclerData.results.addAll(filteredResults);
|
recyclerData.results.addAll(filteredResults);
|
||||||
recyclerData.resultsUnfiltered.addAll(it);
|
recyclerData.resultsUnfiltered.addAll(it);
|
||||||
if(filteredResults.isEmpty()) {
|
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||||
filteredNextPageCounter++
|
ensureEnoughContentVisible(filteredResults)
|
||||||
if(filteredNextPageCounter <= 4)
|
|
||||||
loadNextPage()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
filteredNextPageCounter = 0;
|
|
||||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
|
||||||
}
|
|
||||||
}.exception<Throwable> {
|
}.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load next page.", it);
|
Logger.w(TAG, "Failed to load next page.", it);
|
||||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||||
@@ -170,8 +161,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
val visibleItemCount = _recyclerResults.childCount;
|
val visibleItemCount = _recyclerResults.childCount;
|
||||||
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
|
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
|
||||||
|
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
|
||||||
|
|
||||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
||||||
//Logger.i(TAG, "loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold _results.size=${_results.size}")
|
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||||
loadNextPage();
|
loadNextPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +173,33 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
_recyclerResults.addOnScrollListener(_scrollListener);
|
_recyclerResults.addOnScrollListener(_scrollListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
|
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||||
|
val layoutManager = recyclerData.layoutManager
|
||||||
|
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||||
|
|
||||||
|
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
|
||||||
|
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
|
||||||
|
val itemHeight = firstVisibleView?.height ?: 0
|
||||||
|
val occupiedSpace = recyclerData.results.size * itemHeight
|
||||||
|
val recyclerViewHeight = _recyclerResults.height
|
||||||
|
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
|
||||||
|
occupiedSpace >= recyclerViewHeight
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
||||||
|
if (!canScroll || filteredResults.isEmpty()) {
|
||||||
|
_automaticNextPageCounter++
|
||||||
|
if(_automaticNextPageCounter <= 4)
|
||||||
|
loadNextPage()
|
||||||
|
} else {
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected fun setTextCentered(text: String?) {
|
protected fun setTextCentered(text: String?) {
|
||||||
_textCentered.text = text;
|
_textCentered.text = text;
|
||||||
}
|
}
|
||||||
@@ -352,6 +372,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadPagerInternal(pager: TPager, cache: ItemCache<TResult>? = null) {
|
private fun loadPagerInternal(pager: TPager, cache: ItemCache<TResult>? = null) {
|
||||||
|
Logger.i(TAG, "Setting new internal pager on feed");
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|
||||||
detachPagerEvents();
|
detachPagerEvents();
|
||||||
@@ -369,6 +390,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||||
recyclerData.adapter.notifyDataSetChanged();
|
recyclerData.adapter.notifyDataSetChanged();
|
||||||
recyclerData.loadedFeedStyle = feedStyle;
|
recyclerData.loadedFeedStyle = feedStyle;
|
||||||
|
ensureEnoughContentVisible(filteredResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detachPagerEvents() {
|
private fun detachPagerEvents() {
|
||||||
@@ -397,6 +419,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _lastNextPage = false;
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
synchronized(_pager_lock) {
|
synchronized(_pager_lock) {
|
||||||
val pager: TPager = recyclerData.pager ?: return;
|
val pager: TPager = recyclerData.pager ?: return;
|
||||||
@@ -405,9 +428,14 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
//loadCachedPage();
|
//loadCachedPage();
|
||||||
if (pager.hasMorePages()) {
|
if (pager.hasMorePages()) {
|
||||||
|
_lastNextPage = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
_nextPageHandler.run(pager);
|
_nextPageHandler.run(pager);
|
||||||
}
|
}
|
||||||
|
else if(_lastNextPage) {
|
||||||
|
Logger.i(TAG, "End of page reached");
|
||||||
|
_lastNextPage = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ import androidx.core.widget.addTextChangedListener
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
import com.futo.platformplayer.views.others.TagsView
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
@@ -58,7 +59,7 @@ class HistoryFragment : MainFragment() {
|
|||||||
|
|
||||||
tagsView.onClick.subscribe { timeMinutesToErase ->
|
tagsView.onClick.subscribe { timeMinutesToErase ->
|
||||||
UIDialogs.showConfirmationDialog(requireContext(), getString(R.string.are_you_sure_delete_historical), {
|
UIDialogs.showConfirmationDialog(requireContext(), getString(R.string.are_you_sure_delete_historical), {
|
||||||
StatePlaylists.instance.removeHistoryRange(timeMinutesToErase.second as Long);
|
StateHistory.instance.removeHistoryRange(timeMinutesToErase.second as Long);
|
||||||
UIDialogs.toast(view.context, timeMinutesToErase.first + " " + getString(R.string.removed));
|
UIDialogs.toast(view.context, timeMinutesToErase.first + " " + getString(R.string.removed));
|
||||||
adapter.updateFilteredVideos();
|
adapter.updateFilteredVideos();
|
||||||
adapter.notifyDataSetChanged();
|
adapter.notifyDataSetChanged();
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class HomeFragment : MainFragment() {
|
|||||||
private var _announcementsView: AnnouncementView;
|
private var _announcementsView: AnnouncementView;
|
||||||
|
|
||||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||||
|
|
||||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
_announcementsView = AnnouncementView(context, null).apply {
|
_announcementsView = AnnouncementView(context, null).apply {
|
||||||
|
|||||||
+5
-4
@@ -31,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
|||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fixHtmlWhitespace
|
import com.futo.platformplayer.fixHtmlWhitespace
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||||
@@ -223,7 +224,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
updateCommentType(false);
|
updateCommentType(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commentsList.onClick.subscribe { c ->
|
_commentsList.onRepliesClick.subscribe { c ->
|
||||||
val replyCount = c.replyCount ?: 0;
|
val replyCount = c.replyCount ?: 0;
|
||||||
var metadata = "";
|
var metadata = "";
|
||||||
if (replyCount > 0) {
|
if (replyCount > 0) {
|
||||||
@@ -232,7 +233,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
|
|
||||||
if (c is PolycentricPlatformComment) {
|
if (c is PolycentricPlatformComment) {
|
||||||
var parentComment: PolycentricPlatformComment = c;
|
var parentComment: PolycentricPlatformComment = c;
|
||||||
_repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
|
_repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
|
||||||
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||||
{
|
{
|
||||||
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
||||||
@@ -240,7 +241,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
parentComment = newComment;
|
parentComment = newComment;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_repliesOverlay.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
|
_repliesOverlay.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||||
}
|
}
|
||||||
|
|
||||||
setRepliesOverlayVisible(isVisible = true, animate = true);
|
setRepliesOverlayVisible(isVisible = true, animate = true);
|
||||||
@@ -363,7 +364,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers", e)
|
Logger.e(TAG, "Failed to backfill servers", e)
|
||||||
|
|||||||
+23
-8
@@ -15,13 +15,13 @@ 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.JSClient
|
||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
import com.futo.platformplayer.exceptions.RateLimitException
|
import com.futo.platformplayer.exceptions.RateLimitException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -40,6 +40,8 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
class SubscriptionsFeedFragment : MainFragment() {
|
class SubscriptionsFeedFragment : MainFragment() {
|
||||||
override val isMainView : Boolean = true;
|
override val isMainView : Boolean = true;
|
||||||
@@ -93,6 +95,8 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
@SuppressLint("ViewConstructor")
|
@SuppressLint("ViewConstructor")
|
||||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
|
||||||
|
|
||||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||||
@@ -130,8 +134,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
|
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
|
||||||
loadResults(false);
|
loadResults(false);
|
||||||
else if(recyclerData.results.size == 0)
|
else if(recyclerData.results.size == 0) {
|
||||||
loadCache();
|
loadCache();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val announcementsView = _announcementsView;
|
val announcementsView = _announcementsView;
|
||||||
@@ -304,12 +310,21 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
|
|
||||||
private fun loadCache() {
|
private fun loadCache() {
|
||||||
Logger.i(TAG, "Subscriptions load cache");
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
val cachePager: IPager<IPlatformContent>;
|
||||||
val results = cachePager.getResults();
|
Logger.i(TAG, "Subscriptions retrieving cache");
|
||||||
Logger.i(TAG, "Subscriptions show cache (${results.size})");
|
val time = measureTimeMillis {
|
||||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
cachePager = StateCache.instance.getSubscriptionCachePager();
|
||||||
setPager(cachePager);
|
}
|
||||||
|
Logger.i(TAG, "Subscriptions retrieved cache (${time}ms)");
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val results = cachePager.getResults();
|
||||||
|
Logger.i(TAG, "Subscriptions show cache (${results.size})");
|
||||||
|
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||||
|
setPager(cachePager);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private fun loadResults(withRefetch: Boolean = false) {
|
private fun loadResults(withRefetch: Boolean = false) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
+103
-48
@@ -37,7 +37,6 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
|||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
@@ -52,7 +51,6 @@ 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.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
@@ -60,7 +58,6 @@ import com.futo.platformplayer.casting.StateCasting
|
|||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
@@ -75,6 +72,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
|
|||||||
import com.futo.platformplayer.states.*
|
import com.futo.platformplayer.states.*
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringArrayStorage
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
import com.futo.platformplayer.views.MonetizationView
|
import com.futo.platformplayer.views.MonetizationView
|
||||||
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
||||||
import com.futo.platformplayer.views.casting.CastView
|
import com.futo.platformplayer.views.casting.CastView
|
||||||
@@ -109,7 +107,6 @@ import java.time.OffsetDateTime
|
|||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import kotlin.streams.toList
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDetailView : ConstraintLayout {
|
class VideoDetailView : ConstraintLayout {
|
||||||
@@ -124,7 +121,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private var _searchVideo: IPlatformVideo? = null;
|
private var _searchVideo: IPlatformVideo? = null;
|
||||||
var video: IPlatformVideoDetails? = null
|
var video: IPlatformVideoDetails? = null
|
||||||
private set;
|
private set;
|
||||||
|
var videoLocal: VideoLocal? = null;
|
||||||
private var _playbackTracker: IPlaybackTracker? = null;
|
private var _playbackTracker: IPlaybackTracker? = null;
|
||||||
|
private var _historyIndex: DBHistory.Index? = null;
|
||||||
|
|
||||||
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
|
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
|
||||||
|
|
||||||
@@ -577,7 +576,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_container_content_current = _container_content_main;
|
_container_content_current = _container_content_main;
|
||||||
|
|
||||||
_commentsList.onClick.subscribe { c ->
|
_commentsList.onRepliesClick.subscribe { c ->
|
||||||
val replyCount = c.replyCount ?: 0;
|
val replyCount = c.replyCount ?: 0;
|
||||||
var metadata = "";
|
var metadata = "";
|
||||||
if (replyCount > 0) {
|
if (replyCount > 0) {
|
||||||
@@ -586,7 +585,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
if (c is PolycentricPlatformComment) {
|
if (c is PolycentricPlatformComment) {
|
||||||
var parentComment: PolycentricPlatformComment = c;
|
var parentComment: PolycentricPlatformComment = c;
|
||||||
_container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
|
_container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
|
||||||
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||||
{
|
{
|
||||||
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
||||||
@@ -594,7 +593,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
parentComment = newComment;
|
parentComment = newComment;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_container_content_replies.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
|
_container_content_replies.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||||
}
|
}
|
||||||
switchContentView(_container_content_replies);
|
switchContentView(_container_content_replies);
|
||||||
};
|
};
|
||||||
@@ -772,6 +771,15 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){
|
||||||
|
val current = _historyIndex;
|
||||||
|
if(current == null || current.url != video.url) {
|
||||||
|
val index = StateHistory.instance.getHistoryByVideo(video, true)!!;
|
||||||
|
_historyIndex = index;
|
||||||
|
return@withContext index;
|
||||||
|
}
|
||||||
|
return@withContext current;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Lifecycle
|
//Lifecycle
|
||||||
@@ -1044,10 +1052,32 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||||
}
|
}
|
||||||
|
|
||||||
val video = if(videoDetail is VideoLocal)
|
var videoLocal: VideoLocal? = null;
|
||||||
videoDetail;
|
var video: IPlatformVideoDetails? = null;
|
||||||
else //TODO: Update cached video if it exists with video
|
|
||||||
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
|
if(videoDetail is VideoLocal) {
|
||||||
|
videoLocal = videoDetail;
|
||||||
|
video = videoDetail;
|
||||||
|
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
|
||||||
|
videoTask.invokeOnCompletion { ex ->
|
||||||
|
if(ex != null) {
|
||||||
|
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
|
||||||
|
return@invokeOnCompletion;
|
||||||
|
}
|
||||||
|
val result = videoTask.getCompleted();
|
||||||
|
if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) {
|
||||||
|
this.video = result;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
updateQualitySourcesOverlay(result, videoLocal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else { //TODO: Update cached video if it exists with video
|
||||||
|
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
|
||||||
|
video = videoDetail;
|
||||||
|
}
|
||||||
|
this.videoLocal = videoLocal;
|
||||||
this.video = video;
|
this.video = video;
|
||||||
this._playbackTracker = null;
|
this._playbackTracker = null;
|
||||||
|
|
||||||
@@ -1082,9 +1112,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
me._playbackTracker = tracker;
|
me._playbackTracker = tracker;
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
withContext(Dispatchers.Main) {
|
Logger.e(TAG, "Playback tracker failed", ex);
|
||||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
if(me.video?.isLive == true) withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
|
||||||
};
|
};
|
||||||
|
else withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1094,7 +1128,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_player.setMetadata(video.name, video.author.name);
|
_player.setMetadata(video.name, video.author.name);
|
||||||
|
|
||||||
_toggleCommentType.setValue(Settings.instance.comments.defaultCommentSection == 1, false);
|
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
|
||||||
updateCommentType(true);
|
updateCommentType(true);
|
||||||
|
|
||||||
//UI
|
//UI
|
||||||
@@ -1181,7 +1215,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers", e)
|
Logger.e(TAG, "Failed to backfill servers", e)
|
||||||
@@ -1235,7 +1269,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
|
|
||||||
//Overlay
|
//Overlay
|
||||||
updateQualitySourcesOverlay(video);
|
updateQualitySourcesOverlay(video, videoLocal);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@@ -1248,24 +1282,30 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
updateQueueState();
|
updateQueueState();
|
||||||
|
|
||||||
_historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, false, (toResume.toFloat() / 1000.0f).toLong());
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
|
val historyItem = getHistoryIndex(videoDetail);
|
||||||
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
|
|
||||||
_layoutResume.visibility = View.VISIBLE;
|
|
||||||
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
|
|
||||||
|
|
||||||
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
try {
|
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
|
||||||
delay(8000);
|
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
|
||||||
|
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
|
||||||
|
_layoutResume.visibility = View.VISIBLE;
|
||||||
|
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
|
||||||
|
|
||||||
|
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
delay(8000);
|
||||||
|
_layoutResume.visibility = View.GONE;
|
||||||
|
_textResume.text = "";
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to set resume changes.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
_layoutResume.visibility = View.GONE;
|
_layoutResume.visibility = View.GONE;
|
||||||
_textResume.text = "";
|
_textResume.text = "";
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to set resume changes.", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_layoutResume.visibility = View.GONE;
|
|
||||||
_textResume.text = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1486,6 +1526,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
||||||
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
||||||
_overlay_quality_selector?.show();
|
_overlay_quality_selector?.show();
|
||||||
|
_slideUpOverlay = _overlay_quality_selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prevVideo() {
|
fun prevVideo() {
|
||||||
@@ -1513,9 +1554,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
//Quality Selector data
|
//Quality Selector data
|
||||||
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
||||||
val v = video ?: return;
|
val v = video ?: return;
|
||||||
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats);
|
updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||||
}
|
}
|
||||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||||
Logger.i(TAG, "updateQualitySourcesOverlay");
|
Logger.i(TAG, "updateQualitySourcesOverlay");
|
||||||
|
|
||||||
val video: IPlatformVideoDetails?;
|
val video: IPlatformVideoDetails?;
|
||||||
@@ -1523,24 +1564,35 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val localAudioSource: List<LocalAudioSource>?;
|
val localAudioSource: List<LocalAudioSource>?;
|
||||||
val localSubtitleSources: List<LocalSubtitleSource>?;
|
val localSubtitleSources: List<LocalSubtitleSource>?;
|
||||||
|
|
||||||
|
val videoSources: List<IVideoSource>?;
|
||||||
|
val audioSources: List<IAudioSource>?;
|
||||||
|
|
||||||
if(videoDetails is VideoLocal) {
|
if(videoDetails is VideoLocal) {
|
||||||
video = videoDetails.videoSerialized;
|
video = videoLocal?.videoSerialized;
|
||||||
localVideoSources = videoDetails.videoSource.toList();
|
localVideoSources = videoDetails.videoSource.toList();
|
||||||
localAudioSource = videoDetails.audioSource.toList();
|
localAudioSource = videoDetails.audioSource.toList();
|
||||||
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
||||||
|
videoSources = null
|
||||||
|
audioSources = null;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
video = videoDetails;
|
video = videoDetails;
|
||||||
localVideoSources = null;
|
videoSources = video?.video?.videoSources?.toList();
|
||||||
localAudioSource = null;
|
audioSources = if(video?.video?.isUnMuxed == true)
|
||||||
localSubtitleSources = null;
|
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||||
|
else null
|
||||||
|
if(videoLocal != null) {
|
||||||
|
localVideoSources = videoLocal.videoSource.toList();
|
||||||
|
localAudioSource = videoLocal.audioSource.toList();
|
||||||
|
localSubtitleSources = videoLocal.subtitlesSources.toList();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
localVideoSources = null;
|
||||||
|
localAudioSource = null;
|
||||||
|
localSubtitleSources = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val videoSources = video?.video?.videoSources?.toList();
|
|
||||||
val audioSources = if(video?.video?.isUnMuxed == true)
|
|
||||||
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
|
||||||
else null
|
|
||||||
|
|
||||||
val bestVideoSources = videoSources?.map { it.height * it.width }
|
val bestVideoSources = videoSources?.map { it.height * it.width }
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||||
@@ -1568,7 +1620,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
if(localVideoSources?.isNotEmpty() == true)
|
if(localVideoSources?.isNotEmpty() == true)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
||||||
*localVideoSources.stream()
|
*localVideoSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
|
||||||
{ handleSelectVideoTrack(it) });
|
{ handleSelectVideoTrack(it) });
|
||||||
@@ -1576,7 +1628,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else null,
|
else null,
|
||||||
if(localAudioSource?.isNotEmpty() == true)
|
if(localAudioSource?.isNotEmpty() == true)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
||||||
*localAudioSource.stream()
|
*localAudioSource
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||||
{ handleSelectAudioTrack(it) });
|
{ handleSelectAudioTrack(it) });
|
||||||
@@ -1592,7 +1644,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else null,
|
else null,
|
||||||
if(liveStreamVideoFormats?.isEmpty() == false)
|
if(liveStreamVideoFormats?.isEmpty() == false)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
||||||
*liveStreamVideoFormats.stream()
|
*liveStreamVideoFormats
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
||||||
{ _player.selectVideoTrack(it.height) });
|
{ _player.selectVideoTrack(it.height) });
|
||||||
@@ -1600,7 +1652,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else null,
|
else null,
|
||||||
if(liveStreamAudioFormats?.isEmpty() == false)
|
if(liveStreamAudioFormats?.isEmpty() == false)
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
||||||
*liveStreamAudioFormats.stream()
|
*liveStreamAudioFormats
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
||||||
{ _player.selectAudioTrack(it.bitrate) });
|
{ _player.selectAudioTrack(it.bitrate) });
|
||||||
@@ -1609,7 +1661,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
if(bestVideoSources.isNotEmpty())
|
if(bestVideoSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||||
*bestVideoSources.stream()
|
*bestVideoSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
||||||
{ handleSelectVideoTrack(it) });
|
{ handleSelectVideoTrack(it) });
|
||||||
@@ -1617,7 +1669,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else null,
|
else null,
|
||||||
if(bestAudioSources.isNotEmpty())
|
if(bestAudioSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||||
*bestAudioSources.stream()
|
*bestAudioSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||||
{ handleSelectAudioTrack(it) });
|
{ handleSelectAudioTrack(it) });
|
||||||
@@ -1840,7 +1892,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private fun setCastEnabled(isCasting: Boolean) {
|
private fun setCastEnabled(isCasting: Boolean) {
|
||||||
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
||||||
|
|
||||||
video?.let { updateQualitySourcesOverlay(it); };
|
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
|
||||||
|
|
||||||
_isCasting = isCasting;
|
_isCasting = isCasting;
|
||||||
|
|
||||||
@@ -2049,7 +2101,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val v = video ?: return;
|
val v = video ?: return;
|
||||||
val currentTime = System.currentTimeMillis();
|
val currentTime = System.currentTimeMillis();
|
||||||
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
|
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
|
||||||
StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val history = getHistoryIndex(v);
|
||||||
|
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
|
||||||
|
}
|
||||||
_lastPositionSaveTime = currentTime;
|
_lastPositionSaveTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
@@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
|
|||||||
class VideoHelper {
|
class VideoHelper {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun isDownloadable(detail: IPlatformVideoDetails) =
|
fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
|
||||||
(detail.video.videoSources.any { isDownloadable(it) }) ||
|
if (detail.video.videoSources.any { isDownloadable(it) }) {
|
||||||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
|
return true
|
||||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
|
}
|
||||||
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
|
|
||||||
|
val descriptor = detail.video
|
||||||
|
if (descriptor is VideoUnMuxedSourceDescriptor) {
|
||||||
|
if (descriptor.audioSources.any { isDownloadable(it) }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
|
||||||
|
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
|
||||||
|
|
||||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
package com.futo.platformplayer.parsers
|
package com.futo.platformplayer.parsers
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||||
|
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.IVideoSource
|
||||||
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
|
import com.futo.platformplayer.toYesNo
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import com.futo.platformplayer.yesNoToBoolean
|
import com.futo.platformplayer.yesNoToBoolean
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class HLS {
|
class HLS {
|
||||||
companion object {
|
companion object {
|
||||||
fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist {
|
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||||
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 baseUrl = URI(sourceUrl).resolve("./").toString()
|
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||||
|
|
||||||
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
||||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||||
|
val sessionDataList = mutableListOf<SessionData>()
|
||||||
var independentSegments = false
|
var independentSegments = false
|
||||||
|
|
||||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||||
@@ -31,50 +42,53 @@ class HLS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
line.startsWith("#EXT-X-MEDIA") -> {
|
line.startsWith("#EXT-X-MEDIA") -> {
|
||||||
mediaRenditions.add(parseMediaRendition(client, line, baseUrl))
|
mediaRenditions.add(parseMediaRendition(line, baseUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||||
independentSegments = true
|
independentSegments = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||||
|
val sessionData = parseSessionData(line)
|
||||||
|
sessionDataList.add(sessionData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MasterPlaylist(variantPlaylists, mediaRenditions, independentSegments)
|
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist {
|
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||||
val response = client.get(sourceUrl)
|
|
||||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
|
||||||
|
|
||||||
val content = response.body?.string()
|
|
||||||
?: throw Exception("Variant playlist content is empty")
|
|
||||||
|
|
||||||
val lines = content.lines()
|
val lines = content.lines()
|
||||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3
|
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
?: throw Exception("Target duration not found in variant playlist")
|
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||||
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0
|
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0
|
|
||||||
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||||
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||||
}
|
}
|
||||||
|
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||||
|
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||||
|
|
||||||
val segments = mutableListOf<Segment>()
|
val segments = mutableListOf<Segment>()
|
||||||
var currentSegment: Segment? = null
|
var currentSegment: MediaSegment? = null
|
||||||
lines.forEach { line ->
|
lines.forEachIndexed { index, line ->
|
||||||
when {
|
when {
|
||||||
line.startsWith("#EXTINF:") -> {
|
line.startsWith("#EXTINF:") -> {
|
||||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||||
?: throw Exception("Invalid segment duration format")
|
?: throw Exception("Invalid segment duration format")
|
||||||
currentSegment = Segment(duration = duration)
|
currentSegment = MediaSegment(duration = duration)
|
||||||
}
|
}
|
||||||
line.startsWith("#") -> {
|
line == "#EXT-X-DISCONTINUITY" -> {
|
||||||
// Handle other tags if necessary
|
segments.add(DiscontinuitySegment())
|
||||||
|
}
|
||||||
|
line =="#EXT-X-ENDLIST" -> {
|
||||||
|
segments.add(EndListSegment())
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
currentSegment?.let {
|
currentSegment?.let {
|
||||||
it.uri = line
|
it.uri = resolveUrl(sourceUrl, line)
|
||||||
segments.add(it)
|
segments.add(it)
|
||||||
}
|
}
|
||||||
currentSegment = null
|
currentSegment = null
|
||||||
@@ -82,13 +96,62 @@ class HLS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments)
|
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||||
|
val masterPlaylist: MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = parseMasterPlaylist(content, url)
|
||||||
|
return masterPlaylist.getVideoSources()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
return if (source is IHLSManifestSource) {
|
||||||
|
listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url))
|
||||||
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
|
listOf()
|
||||||
|
} else {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
|
||||||
|
val masterPlaylist: MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = parseMasterPlaylist(content, url)
|
||||||
|
return masterPlaylist.getAudioSources()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
return if (source is IHLSManifestSource) {
|
||||||
|
listOf()
|
||||||
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
|
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
|
||||||
|
} else {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: getSubtitleSources
|
||||||
|
|
||||||
private fun resolveUrl(baseUrl: String, url: String): String {
|
private fun resolveUrl(baseUrl: String, url: String): String {
|
||||||
return if (URI(url).isAbsolute) url else baseUrl + url
|
val baseUri = URI(baseUrl)
|
||||||
}
|
val urlUri = URI(url)
|
||||||
|
|
||||||
|
return if (urlUri.isAbsolute) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
val resolvedUri = baseUri.resolve(urlUri)
|
||||||
|
resolvedUri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseStreamInfo(content: String): StreamInfo {
|
private fun parseStreamInfo(content: String): StreamInfo {
|
||||||
val attributes = parseAttributes(content)
|
val attributes = parseAttributes(content)
|
||||||
@@ -99,17 +162,18 @@ class HLS {
|
|||||||
frameRate = attributes["FRAME-RATE"],
|
frameRate = attributes["FRAME-RATE"],
|
||||||
videoRange = attributes["VIDEO-RANGE"],
|
videoRange = attributes["VIDEO-RANGE"],
|
||||||
audio = attributes["AUDIO"],
|
audio = attributes["AUDIO"],
|
||||||
|
video = attributes["VIDEO"],
|
||||||
|
subtitles = attributes["SUBTITLES"],
|
||||||
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition {
|
private fun parseMediaRendition(line: String, baseUrl: String): MediaRendition {
|
||||||
val attributes = parseAttributes(line)
|
val attributes = parseAttributes(line)
|
||||||
val uri = attributes["URI"]!!
|
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
|
||||||
val url = resolveUrl(baseUrl, uri)
|
|
||||||
return MediaRendition(
|
return MediaRendition(
|
||||||
type = attributes["TYPE"],
|
type = attributes["TYPE"],
|
||||||
uri = url,
|
uri = uri,
|
||||||
groupID = attributes["GROUP-ID"],
|
groupID = attributes["GROUP-ID"],
|
||||||
language = attributes["LANGUAGE"],
|
language = attributes["LANGUAGE"],
|
||||||
name = attributes["NAME"],
|
name = attributes["NAME"],
|
||||||
@@ -119,6 +183,13 @@ class HLS {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseSessionData(line: String): SessionData {
|
||||||
|
val attributes = parseAttributes(line)
|
||||||
|
val dataId = attributes["DATA-ID"]!!
|
||||||
|
val value = attributes["VALUE"]!!
|
||||||
|
return SessionData(dataId, value)
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseAttributes(content: String): Map<String, String> {
|
private fun parseAttributes(content: String): Map<String, String> {
|
||||||
val attributes = mutableMapOf<String, String>()
|
val attributes = mutableMapOf<String, String>()
|
||||||
val attributePairs = content.substringAfter(":").splitToSequence(',')
|
val attributePairs = content.substringAfter(":").splitToSequence(',')
|
||||||
@@ -138,7 +209,7 @@ class HLS {
|
|||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO")
|
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||||
if (value == null)
|
if (value == null)
|
||||||
return false;
|
return false;
|
||||||
@@ -158,6 +229,20 @@ class HLS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SessionData(
|
||||||
|
val dataId: String,
|
||||||
|
val value: String
|
||||||
|
) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-SESSION-DATA:")
|
||||||
|
appendAttributes(this,
|
||||||
|
"DATA-ID" to dataId,
|
||||||
|
"VALUE" to value
|
||||||
|
)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class StreamInfo(
|
data class StreamInfo(
|
||||||
val bandwidth: Int?,
|
val bandwidth: Int?,
|
||||||
val resolution: String?,
|
val resolution: String?,
|
||||||
@@ -165,12 +250,30 @@ class HLS {
|
|||||||
val frameRate: String?,
|
val frameRate: String?,
|
||||||
val videoRange: String?,
|
val videoRange: String?,
|
||||||
val audio: String?,
|
val audio: String?,
|
||||||
|
val video: String?,
|
||||||
|
val subtitles: String?,
|
||||||
val closedCaptions: String?
|
val closedCaptions: String?
|
||||||
)
|
) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-STREAM-INF:")
|
||||||
|
appendAttributes(this,
|
||||||
|
"BANDWIDTH" to bandwidth?.toString(),
|
||||||
|
"RESOLUTION" to resolution,
|
||||||
|
"CODECS" to codecs,
|
||||||
|
"FRAME-RATE" to frameRate,
|
||||||
|
"VIDEO-RANGE" to videoRange,
|
||||||
|
"AUDIO" to audio,
|
||||||
|
"VIDEO" to video,
|
||||||
|
"SUBTITLES" to subtitles,
|
||||||
|
"CLOSED-CAPTIONS" to closedCaptions
|
||||||
|
)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class MediaRendition(
|
data class MediaRendition(
|
||||||
val type: String?,
|
val type: String?,
|
||||||
val uri: String,
|
val uri: String?,
|
||||||
val groupID: String?,
|
val groupID: String?,
|
||||||
val language: String?,
|
val language: String?,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
@@ -186,17 +289,19 @@ class HLS {
|
|||||||
"GROUP-ID" to groupID,
|
"GROUP-ID" to groupID,
|
||||||
"LANGUAGE" to language,
|
"LANGUAGE" to language,
|
||||||
"NAME" to name,
|
"NAME" to name,
|
||||||
"DEFAULT" to isDefault?.toString()?.uppercase(),
|
"DEFAULT" to isDefault.toYesNo(),
|
||||||
"AUTOSELECT" to isAutoSelect?.toString()?.uppercase(),
|
"AUTOSELECT" to isAutoSelect.toYesNo(),
|
||||||
"FORCED" to isForced?.toString()?.uppercase()
|
"FORCED" to isForced.toYesNo()
|
||||||
)
|
)
|
||||||
append("\n")
|
append("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
data class MasterPlaylist(
|
data class MasterPlaylist(
|
||||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||||
val mediaRenditions: List<MediaRendition>,
|
val mediaRenditions: List<MediaRendition>,
|
||||||
|
val sessionDataList: List<SessionData>,
|
||||||
val independentSegments: Boolean
|
val independentSegments: Boolean
|
||||||
) {
|
) {
|
||||||
fun buildM3U8(): String {
|
fun buildM3U8(): String {
|
||||||
@@ -214,53 +319,113 @@ class HLS {
|
|||||||
builder.append(variant.toM3U8Line())
|
builder.append(variant.toM3U8Line())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionDataList.forEach { data ->
|
||||||
|
builder.append(data.toM3U8Line())
|
||||||
|
}
|
||||||
|
|
||||||
return builder.toString()
|
return builder.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||||
|
return variantPlaylistsRefs.map {
|
||||||
|
var width: Int? = null
|
||||||
|
var height: Int? = null
|
||||||
|
val resolutionTokens = it.streamInfo.resolution?.split('x')
|
||||||
|
if (resolutionTokens?.isNotEmpty() == true) {
|
||||||
|
width = resolutionTokens[0].toIntOrNull()
|
||||||
|
height = resolutionTokens[1].toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||||
|
return mediaRenditions.mapNotNull {
|
||||||
|
if (it.uri == null) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
return@mapNotNull when (it.type) {
|
||||||
|
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSubtitleSources(): List<HLSVariantSubtitleUrlSource> {
|
||||||
|
return mediaRenditions.mapNotNull {
|
||||||
|
if (it.uri == null) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
return@mapNotNull when (it.type) {
|
||||||
|
"SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
||||||
fun toM3U8Line(): String = buildString {
|
fun toM3U8Line(): String = buildString {
|
||||||
append("#EXT-X-STREAM-INF:")
|
append(streamInfo.toM3U8Line())
|
||||||
appendAttributes(this,
|
append("$url\n")
|
||||||
"BANDWIDTH" to streamInfo.bandwidth?.toString(),
|
|
||||||
"RESOLUTION" to streamInfo.resolution,
|
|
||||||
"CODECS" to streamInfo.codecs,
|
|
||||||
"FRAME-RATE" to streamInfo.frameRate,
|
|
||||||
"VIDEO-RANGE" to streamInfo.videoRange,
|
|
||||||
"AUDIO" to streamInfo.audio,
|
|
||||||
"CLOSED-CAPTIONS" to streamInfo.closedCaptions
|
|
||||||
)
|
|
||||||
append("\n$url\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VariantPlaylist(
|
data class VariantPlaylist(
|
||||||
val version: Int,
|
val version: Int?,
|
||||||
val targetDuration: Int,
|
val targetDuration: Int?,
|
||||||
val mediaSequence: Long,
|
val mediaSequence: Long?,
|
||||||
val discontinuitySequence: Int,
|
val discontinuitySequence: Int?,
|
||||||
val programDateTime: ZonedDateTime?,
|
val programDateTime: ZonedDateTime?,
|
||||||
|
val playlistType: String?,
|
||||||
|
val streamInfo: StreamInfo?,
|
||||||
val segments: List<Segment>
|
val segments: List<Segment>
|
||||||
) {
|
) {
|
||||||
fun buildM3U8(): String = buildString {
|
fun buildM3U8(): String = buildString {
|
||||||
append("#EXTM3U\n")
|
append("#EXTM3U\n")
|
||||||
append("#EXT-X-VERSION:$version\n")
|
version?.let { append("#EXT-X-VERSION:$it\n") }
|
||||||
append("#EXT-X-TARGETDURATION:$targetDuration\n")
|
targetDuration?.let { append("#EXT-X-TARGETDURATION:$it\n") }
|
||||||
append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n")
|
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||||
append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n")
|
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||||
programDateTime?.let {
|
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||||
append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n")
|
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||||
}
|
streamInfo?.let { append(it.toM3U8Line()) }
|
||||||
|
|
||||||
segments.forEach { segment ->
|
segments.forEach { segment ->
|
||||||
append("#EXTINF:${segment.duration},\n")
|
append(segment.toM3U8Line())
|
||||||
append(segment.uri + "\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Segment(
|
abstract class Segment {
|
||||||
|
abstract fun toM3U8Line(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MediaSegment (
|
||||||
val duration: Double,
|
val duration: Double,
|
||||||
var uri: String = ""
|
var uri: String = ""
|
||||||
)
|
) : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXTINF:${duration},\n")
|
||||||
|
append(uri + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscontinuitySegment : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-DISCONTINUITY\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EndListSegment : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-ENDLIST\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class HttpResponseParser : AutoCloseable {
|
|||||||
|
|
||||||
var contentType: String? = null;
|
var contentType: String? = null;
|
||||||
var transferEncoding: String? = null;
|
var transferEncoding: String? = null;
|
||||||
|
var location: String? = null;
|
||||||
var contentLength: Long = -1L;
|
var contentLength: Long = -1L;
|
||||||
|
|
||||||
var statusCode: Int = -1;
|
var statusCode: Int = -1;
|
||||||
@@ -47,6 +48,7 @@ class HttpResponseParser : AutoCloseable {
|
|||||||
"content-length" -> contentLength = headerValue.toLong();
|
"content-length" -> contentLength = headerValue.toLong();
|
||||||
"content-type" -> contentType = headerValue;
|
"content-type" -> contentType = headerValue;
|
||||||
"transfer-encoding" -> transferEncoding = headerValue;
|
"transfer-encoding" -> transferEncoding = headerValue;
|
||||||
|
"location" -> location = headerValue;
|
||||||
}
|
}
|
||||||
if(line.isNullOrEmpty())
|
if(line.isNullOrEmpty())
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import com.futo.platformplayer.constructs.BatchedTaskHandler
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||||
import com.futo.platformplayer.getNowDiffSeconds
|
import com.futo.platformplayer.getNowDiffSeconds
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
|
||||||
import com.futo.platformplayer.resolveChannelUrls
|
import com.futo.platformplayer.resolveChannelUrls
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
|
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.google.protobuf.ByteString
|
import com.google.protobuf.ByteString
|
||||||
@@ -19,17 +19,21 @@ import java.nio.ByteBuffer
|
|||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class PolycentricCache {
|
class PolycentricCache {
|
||||||
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now());
|
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now()) {
|
||||||
|
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
|
||||||
|
}
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now());
|
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) {
|
||||||
|
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
private val _cacheExpirationSeconds = 60 * 60 * 3;
|
|
||||||
private val _cache = hashMapOf<PlatformID, CachedOwnedClaims>()
|
private val _cache = hashMapOf<PlatformID, CachedOwnedClaims>()
|
||||||
private val _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
|
private val _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
|
||||||
private val _profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
|
private val _profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
|
||||||
private val _scope = CoroutineScope(Dispatchers.IO);
|
private val _scope = CoroutineScope(Dispatchers.IO);
|
||||||
|
|
||||||
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope, { system ->
|
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
|
||||||
|
{ system ->
|
||||||
val signedProfileEvents = ApiMethods.getQueryLatest(
|
val signedProfileEvents = ApiMethods.getQueryLatest(
|
||||||
SERVER,
|
SERVER,
|
||||||
system.toProto(),
|
system.toProto(),
|
||||||
@@ -140,7 +144,7 @@ class PolycentricCache {
|
|||||||
{ _, _ -> });
|
{ _, _ -> });
|
||||||
|
|
||||||
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
|
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
|
||||||
if (id.claimType <= 0) {
|
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
|
||||||
return CachedOwnedClaims(null);
|
return CachedOwnedClaims(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +154,7 @@ class PolycentricCache {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
|
if (!ignoreExpired && cached.expired) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +164,7 @@ class PolycentricCache {
|
|||||||
|
|
||||||
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
|
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
|
||||||
fun getValidClaimsAsync(id: PlatformID): Deferred<CachedOwnedClaims> {
|
fun getValidClaimsAsync(id: PlatformID): Deferred<CachedOwnedClaims> {
|
||||||
if (id.value == null || id.claimType <= 0) {
|
if (!StatePolycentric.instance.enabled || id.value == null || id.claimType <= 0) {
|
||||||
return _scope.async { CachedOwnedClaims(null) };
|
return _scope.async { CachedOwnedClaims(null) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,13 +186,18 @@ class PolycentricCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getDataAsync(url: String): Deferred<ByteBuffer> {
|
fun getDataAsync(url: String): Deferred<ByteBuffer> {
|
||||||
|
StatePolycentric.instance.ensureEnabled()
|
||||||
return _batchTaskGetData.execute(url);
|
return _batchTaskGetData.execute(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
||||||
|
if (!StatePolycentric.instance.enabled) {
|
||||||
|
return CachedPolycentricProfile(null)
|
||||||
|
}
|
||||||
|
|
||||||
synchronized (_profileCache) {
|
synchronized (_profileCache) {
|
||||||
val cached = _profileUrlCache.get(url) ?: return null;
|
val cached = _profileUrlCache.get(url) ?: return null;
|
||||||
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
|
if (!ignoreExpired && cached.expired) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,9 +206,13 @@ class PolycentricCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
||||||
|
if (!StatePolycentric.instance.enabled) {
|
||||||
|
return CachedPolycentricProfile(null)
|
||||||
|
}
|
||||||
|
|
||||||
synchronized(_profileCache) {
|
synchronized(_profileCache) {
|
||||||
val cached = _profileCache[system] ?: return null;
|
val cached = _profileCache[system] ?: return null;
|
||||||
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
|
if (!ignoreExpired && cached.expired) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +221,7 @@ class PolycentricCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getProfileAsync(id: PlatformID): CachedPolycentricProfile? {
|
suspend fun getProfileAsync(id: PlatformID): CachedPolycentricProfile? {
|
||||||
if (id.claimType <= 0) {
|
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
|
||||||
return CachedPolycentricProfile(null);
|
return CachedPolycentricProfile(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +247,10 @@ class PolycentricCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getProfileAsync(system: PublicKey): Deferred<CachedPolycentricProfile?> {
|
fun getProfileAsync(system: PublicKey): Deferred<CachedPolycentricProfile?> {
|
||||||
|
if (!StatePolycentric.instance.enabled) {
|
||||||
|
return _scope.async { CachedPolycentricProfile(null) };
|
||||||
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "getProfileAsync (system: ${system})")
|
Logger.i(TAG, "getProfileAsync (system: ${system})")
|
||||||
val def = _taskGetProfile.execute(system);
|
val def = _taskGetProfile.execute(system);
|
||||||
def.invokeOnCompletion {
|
def.invokeOnCompletion {
|
||||||
@@ -281,6 +298,7 @@ class PolycentricCache {
|
|||||||
private const val TAG = "PolycentricCache"
|
private const val TAG = "PolycentricCache"
|
||||||
const val SERVER = "https://srv1-stg.polycentric.io"
|
const val SERVER = "https://srv1-stg.polycentric.io"
|
||||||
private var _instance: PolycentricCache? = null;
|
private var _instance: PolycentricCache? = null;
|
||||||
|
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3;
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val instance: PolycentricCache
|
val instance: PolycentricCache
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.serializers
|
|||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformPost
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import kotlinx.serialization.DeserializationStrategy
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
@@ -22,7 +23,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
|
|||||||
"MEDIA" -> SerializedPlatformVideo.serializer();
|
"MEDIA" -> SerializedPlatformVideo.serializer();
|
||||||
"NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
|
"NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
|
||||||
"ARTICLE" -> throw NotImplementedError("Articles not yet implemented");
|
"ARTICLE" -> throw NotImplementedError("Articles not yet implemented");
|
||||||
"POST" -> throw NotImplementedError("Post not yet implemented");
|
"POST" -> SerializedPlatformPost.serializer();
|
||||||
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}")
|
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}")
|
||||||
};
|
};
|
||||||
else
|
else
|
||||||
@@ -30,7 +31,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
|
|||||||
ContentType.MEDIA.value -> SerializedPlatformVideo.serializer();
|
ContentType.MEDIA.value -> SerializedPlatformVideo.serializer();
|
||||||
ContentType.NESTED_VIDEO.value -> SerializedPlatformNestedContent.serializer();
|
ContentType.NESTED_VIDEO.value -> SerializedPlatformNestedContent.serializer();
|
||||||
ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented");
|
ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented");
|
||||||
ContentType.POST.value -> throw NotImplementedError("Post not yet implemented");
|
ContentType.POST.value -> SerializedPlatformPost.serializer();
|
||||||
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}")
|
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ class DownloadService : Service() {
|
|||||||
Logger.i(TAG, "doDownloading - Ending Downloads");
|
Logger.i(TAG, "doDownloading - Ending Downloads");
|
||||||
stopService(this);
|
stopService(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun doDownload(download: VideoDownload) {
|
private suspend fun doDownload(download: VideoDownload) {
|
||||||
if(!Settings.instance.downloads.shouldDownload())
|
if(!Settings.instance.downloads.shouldDownload())
|
||||||
throw IllegalStateException("Downloading disabled on current network");
|
throw IllegalStateException("Downloading disabled on current network");
|
||||||
@@ -183,14 +185,14 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
Logger.i(TAG, "Preparing [${download.name}] started");
|
Logger.i(TAG, "Preparing [${download.name}] started");
|
||||||
if(download.state == VideoDownload.State.PREPARING)
|
if(download.state == VideoDownload.State.PREPARING)
|
||||||
download.prepare();
|
download.prepare(_client);
|
||||||
download.changeState(VideoDownload.State.DOWNLOADING);
|
download.changeState(VideoDownload.State.DOWNLOADING);
|
||||||
notifyDownload(download);
|
notifyDownload(download);
|
||||||
|
|
||||||
var lastNotifyTime: Long = 0L;
|
var lastNotifyTime: Long = 0L;
|
||||||
Logger.i(TAG, "Downloading [${download.name}] started");
|
Logger.i(TAG, "Downloading [${download.name}] started");
|
||||||
//TODO: Use plugin client?
|
//TODO: Use plugin client?
|
||||||
download.download(_client) { progress ->
|
download.download(applicationContext, _client) { progress ->
|
||||||
download.progress = progress;
|
download.progress = progress;
|
||||||
|
|
||||||
val currentTime = System.currentTimeMillis();
|
val currentTime = System.currentTimeMillis();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.bumptech.glide.request.target.CustomTarget
|
|||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
@@ -49,6 +50,7 @@ class MediaPlaybackService : Service() {
|
|||||||
private var _mediaSession: MediaSessionCompat? = null;
|
private var _mediaSession: MediaSessionCompat? = null;
|
||||||
private var _hasFocus: Boolean = false;
|
private var _hasFocus: Boolean = false;
|
||||||
private var _focusRequest: AudioFocusRequest? = null;
|
private var _focusRequest: AudioFocusRequest? = null;
|
||||||
|
private var _audioFocusLossTime_ms: Long? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Logger.v(TAG, "onStartCommand");
|
Logger.v(TAG, "onStartCommand");
|
||||||
@@ -335,16 +337,32 @@ class MediaPlaybackService : Service() {
|
|||||||
//Do not start playing on gaining audo focus
|
//Do not start playing on gaining audo focus
|
||||||
//MediaControlReceiver.onPlayReceived.emit();
|
//MediaControlReceiver.onPlayReceived.emit();
|
||||||
_hasFocus = true;
|
_hasFocus = true;
|
||||||
Log.i(TAG, "Audio focus gained");
|
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms)");
|
||||||
|
|
||||||
|
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
||||||
|
val lossTime_ms = _audioFocusLossTime_ms
|
||||||
|
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
|
||||||
|
MediaControlReceiver.onPlayReceived.emit()
|
||||||
|
}
|
||||||
|
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
||||||
|
val lossTime_ms = _audioFocusLossTime_ms
|
||||||
|
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
|
||||||
|
MediaControlReceiver.onPlayReceived.emit()
|
||||||
|
}
|
||||||
|
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
||||||
|
MediaControlReceiver.onPlayReceived.emit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
|
_audioFocusLossTime_ms = System.currentTimeMillis()
|
||||||
Log.i(TAG, "Audio focus transient loss");
|
Log.i(TAG, "Audio focus transient loss");
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||||
Log.i(TAG, "Audio focus transient loss, can duck");
|
Log.i(TAG, "Audio focus transient loss, can duck");
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
|
_audioFocusLossTime_ms = System.currentTimeMillis()
|
||||||
_hasFocus = false;
|
_hasFocus = false;
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
Log.i(TAG, "Audio focus lost");
|
Log.i(TAG, "Audio focus lost");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -256,9 +257,6 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun registerDidYouKnow() {
|
fun registerDidYouKnow() {
|
||||||
val random = Random();
|
val random = Random();
|
||||||
val message: String? = when (random.nextInt(4 * 18 + 1)) {
|
val message: String? = when (random.nextInt(4 * 18 + 1)) {
|
||||||
@@ -294,6 +292,23 @@ class StateAnnouncement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun registerDefaultHandlerAnnouncement() {
|
||||||
|
registerAnnouncement(
|
||||||
|
"default-url-handler",
|
||||||
|
"Allow Grayjay to open URLs",
|
||||||
|
"Click here to allow Grayjay to open URLs",
|
||||||
|
AnnouncementType.SESSION_RECURRING,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"Allow"
|
||||||
|
) {
|
||||||
|
UIDialogs.showUrlHandlingPrompt(StateApp.instance.context) {
|
||||||
|
instance.neverAnnouncement("default-url-handler")
|
||||||
|
instance.onAnnouncementChanged.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var _instance: StateAnnouncement? = null;
|
private var _instance: StateAnnouncement? = null;
|
||||||
val instance: StateAnnouncement
|
val instance: StateAnnouncement
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import androidx.activity.ComponentActivity
|
import android.util.Xml
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -28,12 +23,11 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.activities.CaptchaActivity
|
import com.futo.platformplayer.activities.CaptchaActivity
|
||||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.Serializer
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
@@ -43,20 +37,23 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
|
|||||||
import com.futo.platformplayer.logging.FileLogConsumer
|
import com.futo.platformplayer.logging.FileLogConsumer
|
||||||
import com.futo.platformplayer.logging.LogLevel
|
import com.futo.platformplayer.logging.LogLevel
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
||||||
|
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||||
import com.futo.platformplayer.services.DownloadService
|
import com.futo.platformplayer.services.DownloadService
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.stripe.android.core.utils.encodeToJson
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
import kotlin.time.measureTime
|
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* This class contains global context for unconventional cases where obtaining context is hard.
|
* This class contains global context for unconventional cases where obtaining context is hard.
|
||||||
@@ -66,20 +63,6 @@ import kotlin.time.measureTime
|
|||||||
class StateApp {
|
class StateApp {
|
||||||
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
|
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
|
||||||
|
|
||||||
/*
|
|
||||||
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
|
|
||||||
|
|
||||||
fun getExternalRootDirectory(): File? {
|
|
||||||
if(!externalRootDirectory.exists()) {
|
|
||||||
val result = externalRootDirectory.mkdirs();
|
|
||||||
if(!result)
|
|
||||||
return null;
|
|
||||||
return externalRootDirectory;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return externalRootDirectory;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
||||||
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
||||||
if(isValidStorageUri(context, generalUri))
|
if(isValidStorageUri(context, generalUri))
|
||||||
@@ -235,14 +218,33 @@ class StateApp {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, handle: (DocumentFile?)->Unit) {
|
fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, contentType: String, handle: (DocumentFile?)->Unit) {
|
||||||
if(activity is Context) {
|
if(activity is Context) {
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
if(path != null)
|
if(path != null)
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
intent.setType(contentType);
|
||||||
|
activity.launchForResult(intent, 98) {
|
||||||
|
if(it.resultCode == Activity.RESULT_OK) {
|
||||||
|
val uri = it.data?.data;
|
||||||
|
if(uri != null)
|
||||||
|
handle(DocumentFile.fromSingleUri(activity, uri));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun requestFileCreateAccess(activity: IWithResultLauncher, path: Uri?, contentType: String, handle: (DocumentFile?)->Unit) {
|
||||||
|
if(activity is Context) {
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||||
|
if(path != null)
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||||
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
intent.setType(contentType);
|
||||||
activity.launchForResult(intent, 98) {
|
activity.launchForResult(intent, 98) {
|
||||||
if(it.resultCode == Activity.RESULT_OK) {
|
if(it.resultCode == Activity.RESULT_OK) {
|
||||||
val uri = it.data?.data;
|
val uri = it.data?.data;
|
||||||
@@ -408,7 +410,7 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]");
|
Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]");
|
||||||
val time = measureTimeMillis {
|
val time = measureTimeMillis {
|
||||||
ChannelContentCache.instance;
|
StateCache.instance;
|
||||||
}
|
}
|
||||||
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
|
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -543,9 +545,37 @@ class StateApp {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StateAnnouncement.instance.registerDefaultHandlerAnnouncement();
|
||||||
StateAnnouncement.instance.registerDidYouKnow();
|
StateAnnouncement.instance.registerDidYouKnow();
|
||||||
Logger.i(TAG, "MainApp Started: Finished");
|
Logger.i(TAG, "MainApp Started: Finished");
|
||||||
|
|
||||||
|
StatePlaylists.instance.toMigrateCheck();
|
||||||
|
|
||||||
|
if(StateHistory.instance.shouldMigrateLegacyHistory())
|
||||||
|
StateHistory.instance.migrateLegacyHistory();
|
||||||
|
|
||||||
|
|
||||||
|
if(false) {
|
||||||
|
/*
|
||||||
|
Logger.i(TAG, "TEST:--------(200)---------");
|
||||||
|
testHistoryDB(200);
|
||||||
|
Logger.i(TAG, "TEST:--------(1000)---------");
|
||||||
|
testHistoryDB(1000);
|
||||||
|
Logger.i(TAG, "TEST:--------(2000)---------");
|
||||||
|
testHistoryDB(2000);
|
||||||
|
Logger.i(TAG, "TEST:--------(4000)---------");
|
||||||
|
testHistoryDB(4000);
|
||||||
|
Logger.i(TAG, "TEST:--------(6000)---------");
|
||||||
|
testHistoryDB(6000);
|
||||||
|
Logger.i(TAG, "TEST:--------(100000)---------");
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
StateHistory.instance.testHistoryDB(100000);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mainAppStartedWithExternalFiles(context: Context) {
|
fun mainAppStartedWithExternalFiles(context: Context) {
|
||||||
if(!Settings.instance.didFirstStart) {
|
if(!Settings.instance.didFirstStart) {
|
||||||
if(StateBackup.hasAutomaticBackup()) {
|
if(StateBackup.hasAutomaticBackup()) {
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.copyTo
|
import com.futo.platformplayer.copyTo
|
||||||
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
|
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
|
||||||
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
|
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
|
||||||
import com.futo.platformplayer.getNowDiffHours
|
import com.futo.platformplayer.getNowDiffHours
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.readBytes
|
import com.futo.platformplayer.readBytes
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.writeBytes
|
import com.futo.platformplayer.writeBytes
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -52,15 +56,6 @@ class StateBackup {
|
|||||||
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
|
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
|
||||||
return Pair(mainBackupFile, secondaryBackupFile);
|
return Pair(mainBackupFile, secondaryBackupFile);
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
private fun getAutomaticBackupFiles(): Pair<File, File> {
|
|
||||||
val dir = StateApp.instance.getExternalRootDirectory();
|
|
||||||
if(dir == null)
|
|
||||||
throw IllegalStateException("Can't access external files");
|
|
||||||
return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
|
|
||||||
}*/
|
|
||||||
|
|
||||||
|
|
||||||
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
|
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
|
||||||
StateSubscriptions.instance.toMigrateCheck(),
|
StateSubscriptions.instance.toMigrateCheck(),
|
||||||
StatePlaylists.instance.toMigrateCheck()
|
StatePlaylists.instance.toMigrateCheck()
|
||||||
@@ -192,7 +187,19 @@ class StateBackup {
|
|||||||
importZipBytes(context, scope, backupBytes);
|
importZipBytes(context, scope, backupBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startExternalBackup() {
|
fun saveExternalBackup(activity: IWithResultLauncher) {
|
||||||
|
val data = export();
|
||||||
|
if(activity is Context)
|
||||||
|
StateApp.instance.requestFileCreateAccess(activity, null, "application/zip") {
|
||||||
|
if(it == null) {
|
||||||
|
UIDialogs.toast("Cancelled");
|
||||||
|
return@requestFileCreateAccess;
|
||||||
|
}
|
||||||
|
it.writeBytes(activity, data.asZip());
|
||||||
|
UIDialogs.toast("Export saved");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fun shareExternalBackup() {
|
||||||
val data = export();
|
val data = export();
|
||||||
val now = OffsetDateTime.now();
|
val now = OffsetDateTime.now();
|
||||||
val exportFile = File(
|
val exportFile = File(
|
||||||
@@ -401,6 +408,46 @@ class StateBackup {
|
|||||||
).withCondition { doImport } else null
|
).withCondition { doImport } else null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
||||||
|
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
|
||||||
|
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
|
||||||
|
context.navigate(context.getFragment<ImportSubscriptionsFragment>(), lines);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if(allowFailure) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Unknown text header [${text}]");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fun importNewPipeSubs(context: MainActivity, json: String) {
|
||||||
|
val newPipeSubsParsed = JsonParser.parseString(json).asJsonObject;
|
||||||
|
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Invalid json");
|
||||||
|
else {
|
||||||
|
importNewPipeSubs(context, newPipeSubsParsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun importNewPipeSubs(context: MainActivity, obj: JsonObject) {
|
||||||
|
try {
|
||||||
|
val jsonSubs = obj["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);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.navigate(context.getFragment<ImportSubscriptionsFragment>(), subs);
|
||||||
|
}
|
||||||
|
catch(ex: Exception) {
|
||||||
|
Logger.e("StateBackup", ex.message, ex);
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExportStructure(
|
class ExportStructure(
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
|
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.stores.db.ManagedDBStore
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBSubscriptionCache
|
||||||
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import kotlin.streams.asSequence
|
||||||
|
import kotlin.streams.toList
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
class StateCache {
|
||||||
|
private val _subscriptionCache = ManagedDBStore.create("subscriptionCache", DBSubscriptionCache.Descriptor(), PlatformContentSerializer())
|
||||||
|
.load();
|
||||||
|
|
||||||
|
val channelCacheStartupCount = _subscriptionCache.count();
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_subscriptionCache.deleteAll();
|
||||||
|
}
|
||||||
|
fun clearToday() {
|
||||||
|
val today = _subscriptionCache.queryGreater(DBSubscriptionCache.Index::datetime, OffsetDateTime.now().toEpochSecond());
|
||||||
|
for(content in today)
|
||||||
|
_subscriptionCache.delete(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChannelCachePager(channelUrl: String): IPager<IPlatformContent> {
|
||||||
|
val result: IPager<IPlatformContent>;
|
||||||
|
val time = measureTimeMillis {
|
||||||
|
result = _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) {
|
||||||
|
it.obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
fun getAllChannelCachePager(channelUrls: List<String>): IPager<IPlatformContent> {
|
||||||
|
val result: IPager<IPlatformContent>;
|
||||||
|
val time = measureTimeMillis {
|
||||||
|
result = _subscriptionCache.queryInPager(DBSubscriptionCache.Index::channelUrl, channelUrls, 20) {
|
||||||
|
it.obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
fun getChannelCachePager(channelUrls: List<String>): IPager<IPlatformContent> {
|
||||||
|
val pagers = MultiChronoContentPager(channelUrls.map { _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, it, 20) {
|
||||||
|
it.obj;
|
||||||
|
} }, false, 20);
|
||||||
|
return DedupContentPager(pagers, StatePlatform.instance.getEnabledClients().map { it.id });
|
||||||
|
}
|
||||||
|
fun getSubscriptionCachePager(): DedupContentPager {
|
||||||
|
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
||||||
|
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||||
|
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||||
|
val allUrls = subs.map {
|
||||||
|
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
||||||
|
if(!otherUrls.contains(it.channel.url))
|
||||||
|
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
|
||||||
|
else
|
||||||
|
return@map otherUrls;
|
||||||
|
}.flatten().distinct();
|
||||||
|
|
||||||
|
Logger.i(TAG, "Subscriptions CachePager get pagers");
|
||||||
|
val pagers: List<IPager<IPlatformContent>>;
|
||||||
|
|
||||||
|
val timeCacheRetrieving = measureTimeMillis {
|
||||||
|
pagers = listOf(getAllChannelCachePager(allUrls));
|
||||||
|
|
||||||
|
/*allUrls.parallelStream()
|
||||||
|
.map {
|
||||||
|
getChannelCachePager(it)
|
||||||
|
}
|
||||||
|
.asSequence()
|
||||||
|
.toList();*/
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Subscriptions CachePager compiling (retrieved in ${timeCacheRetrieving}ms)");
|
||||||
|
val pager = MultiChronoContentPager(pagers, false, 20);
|
||||||
|
pager.initialize();
|
||||||
|
Logger.i(TAG, "Subscriptions CachePager compiled");
|
||||||
|
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getCachedContent(url: String): DBSubscriptionCache.Index? {
|
||||||
|
return _subscriptionCache.query(DBSubscriptionCache.Index::url, url).firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uncacheContent(content: SerializedPlatformContent) {
|
||||||
|
val item = getCachedContent(content.url);
|
||||||
|
if(item != null)
|
||||||
|
_subscriptionCache.delete(item);
|
||||||
|
}
|
||||||
|
fun cacheContents(contents: List<IPlatformContent>, doUpdate: Boolean = false): List<IPlatformContent> {
|
||||||
|
return contents.filter { cacheContent(it, doUpdate) };
|
||||||
|
}
|
||||||
|
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||||
|
if(content.author.url.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
val serialized = SerializedPlatformContent.fromContent(content);
|
||||||
|
val existing = getCachedContent(content.url);
|
||||||
|
|
||||||
|
if(existing != null && doUpdate) {
|
||||||
|
_subscriptionCache.update(existing.id!!, serialized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if(existing == null) {
|
||||||
|
_subscriptionCache.insert(serialized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "StateCache";
|
||||||
|
|
||||||
|
private var _instance : StateCache? = null;
|
||||||
|
val instance : StateCache
|
||||||
|
get(){
|
||||||
|
if(_instance == null)
|
||||||
|
_instance = StateCache();
|
||||||
|
return _instance!!;
|
||||||
|
};
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
_instance?.let {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||||
|
return ChannelContentCachePager(pager, scope, onNewCacheHit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ChannelContentCachePager(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 = StateCache.instance.cacheContents(results, true);
|
||||||
|
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();
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val newCacheItemsCount: Int;
|
||||||
|
val ms = measureTimeMillis {
|
||||||
|
val newCacheItems = instance.cacheContents(results, true);
|
||||||
|
newCacheItemsCount = newCacheItems.size;
|
||||||
|
if(onNewCacheItem != null)
|
||||||
|
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||||
|
}
|
||||||
|
Logger.i(TAG, "Caching ${results.size} subscription results, updated ${newCacheItemsCount} (${ms}ms)");
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to cache ${results.size} videos.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResults(): List<IPlatformContent> {
|
||||||
|
val results = pager.getResults();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.constructs.Event2
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
|
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
class StateHistory {
|
||||||
|
//Legacy
|
||||||
|
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
|
||||||
|
.withRestore(object: ReconstructStore<HistoryVideo>() {
|
||||||
|
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
|
||||||
|
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
|
||||||
|
= HistoryVideo.fromReconString(backup, null);
|
||||||
|
})
|
||||||
|
.load();
|
||||||
|
|
||||||
|
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
|
||||||
|
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
|
||||||
|
.withIndex({ it.url }, historyIndex, false, true)
|
||||||
|
.load();
|
||||||
|
|
||||||
|
var onHistoricVideoChanged = Event2<IPlatformVideo, Long>();
|
||||||
|
|
||||||
|
fun shouldMigrateLegacyHistory(): Boolean {
|
||||||
|
return _historyDBStore.count() == 0 && _historyStore.count() > 0;
|
||||||
|
}
|
||||||
|
fun migrateLegacyHistory() {
|
||||||
|
Logger.i(StatePlaylists.TAG, "Migrating legacy history");
|
||||||
|
_historyDBStore.deleteAll();
|
||||||
|
val allHistory = _historyStore.getItems();
|
||||||
|
Logger.i(StatePlaylists.TAG, "Migrating legacy history (${allHistory.size}) items");
|
||||||
|
for(item in allHistory) {
|
||||||
|
_historyDBStore.insert(item);
|
||||||
|
}
|
||||||
|
_historyStore.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getHistoryPosition(url: String): Long {
|
||||||
|
return historyIndex[url]?.position ?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long {
|
||||||
|
val pos = if(position < 0) 0 else position;
|
||||||
|
if(index.obj == null) throw IllegalStateException("Can only update history with a deserialized db item");
|
||||||
|
val historyVideo = index.obj!!;
|
||||||
|
|
||||||
|
val positionBefore = historyVideo.position;
|
||||||
|
if (updateExisting) {
|
||||||
|
var shouldUpdate = false;
|
||||||
|
if (positionBefore < 30) {
|
||||||
|
shouldUpdate = true;
|
||||||
|
} else {
|
||||||
|
if (position > 30) {
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
|
||||||
|
//A unrecovered item
|
||||||
|
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
|
||||||
|
historyVideo.video = SerializedPlatformVideo.fromVideo(liveObj);
|
||||||
|
|
||||||
|
historyVideo.position = pos;
|
||||||
|
historyVideo.date = OffsetDateTime.now();
|
||||||
|
_historyDBStore.update(index.id!!, historyVideo);
|
||||||
|
onHistoricVideoChanged.emit(liveObj, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positionBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positionBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHistoryLegacy(): List<HistoryVideo> {
|
||||||
|
return _historyStore.getItems();
|
||||||
|
}
|
||||||
|
fun getHistory() : List<HistoryVideo> {
|
||||||
|
return _historyDBStore.getAllObjects();
|
||||||
|
//return _historyStore.getItems().sortedByDescending { it.date };
|
||||||
|
}
|
||||||
|
fun getHistoryPager(): IPager<HistoryVideo> {
|
||||||
|
return _historyDBStore.getObjectPager();
|
||||||
|
}
|
||||||
|
fun getHistorySearchPager(query: String): IPager<HistoryVideo> {
|
||||||
|
return _historyDBStore.queryLikeObjectPager(DBHistory.Index::url, "%${query}%", 10);
|
||||||
|
}
|
||||||
|
fun getHistoryIndexByUrl(url: String): DBHistory.Index? {
|
||||||
|
return historyIndex[url];
|
||||||
|
}
|
||||||
|
fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? {
|
||||||
|
val existing = historyIndex[video.url];
|
||||||
|
if(existing != null)
|
||||||
|
return _historyDBStore.get(existing.id!!);
|
||||||
|
else if(create) {
|
||||||
|
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
|
||||||
|
val id = _historyDBStore.insert(newHistItem);
|
||||||
|
return _historyDBStore.get(id);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeHistory(url: String) {
|
||||||
|
val hist = getHistoryIndexByUrl(url);
|
||||||
|
if(hist != null)
|
||||||
|
_historyDBStore.delete(hist.id!!);
|
||||||
|
/*
|
||||||
|
val hist = _historyStore.findItem { it.video.url == url };
|
||||||
|
if(hist != null)
|
||||||
|
_historyStore.delete(hist);*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeHistoryRange(minutesToDelete: Long) {
|
||||||
|
val now = OffsetDateTime.now().toEpochSecond();
|
||||||
|
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
|
||||||
|
for(item in toDelete)
|
||||||
|
_historyDBStore.delete(item);
|
||||||
|
/*
|
||||||
|
val now = OffsetDateTime.now();
|
||||||
|
val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete };
|
||||||
|
|
||||||
|
for(item in toDelete)
|
||||||
|
_historyStore.delete(item);*/
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TAG = "StateHistory";
|
||||||
|
private var _instance : StateHistory? = null;
|
||||||
|
val instance : StateHistory
|
||||||
|
get(){
|
||||||
|
if(_instance == null)
|
||||||
|
_instance = StateHistory();
|
||||||
|
return _instance!!;
|
||||||
|
};
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
_instance?.let {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun testHistoryDB(count: Int) {
|
||||||
|
Logger.i(TAG, "TEST: Starting tests");
|
||||||
|
_historyDBStore.deleteAll();
|
||||||
|
|
||||||
|
val testHistoryItem = getHistoryLegacy().first();
|
||||||
|
val testItemJson = testHistoryItem.video.toJson();
|
||||||
|
val now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
val testSet = (0..count).map { HistoryVideo(Json.decodeFromString<SerializedPlatformVideo>(testItemJson.replace(testHistoryItem.video.url, UUID.randomUUID().toString())), it.toLong(), now.minusHours(it.toLong())) }
|
||||||
|
|
||||||
|
|
||||||
|
Logger.i(TAG, "TEST: Inserting (${testSet.size})");
|
||||||
|
val insertMS = measureTimeMillis {
|
||||||
|
for(item in testSet)
|
||||||
|
_historyDBStore.insert(item);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "TEST: Inserting in ${insertMS}ms");
|
||||||
|
|
||||||
|
var fetched: List<DBHistory.Index>? = null;
|
||||||
|
val fetchMS = measureTimeMillis {
|
||||||
|
fetched = _historyDBStore.getAll();
|
||||||
|
Logger.i(TAG, "TEST: Fetched: ${fetched?.size}");
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "TEST: Fetch speed ${fetchMS}MS");
|
||||||
|
val deserializeMS = measureTimeMillis {
|
||||||
|
val deserialized = _historyDBStore.convertObjects(fetched!!);
|
||||||
|
Logger.i(TAG, "TEST: Deserialized: ${deserialized.size}");
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "TEST: Deserialize speed ${deserializeMS}MS");
|
||||||
|
|
||||||
|
var fetchedIndex: List<DBHistory.Index>? = null;
|
||||||
|
val fetchIndexMS = measureTimeMillis {
|
||||||
|
fetchedIndex = _historyDBStore.getAllIndexes();
|
||||||
|
Logger.i(TAG, "TEST: Fetched Index: ${fetchedIndex!!.size}");
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "TEST: Fetched Index speed ${fetchIndexMS}ms");
|
||||||
|
val fetchFromIndex = measureTimeMillis {
|
||||||
|
for(preItem in testSet) {
|
||||||
|
val item = historyIndex[preItem.video.url];
|
||||||
|
if(item == null)
|
||||||
|
throw IllegalStateException("Missing item [${preItem.video.url}]");
|
||||||
|
if(item.url != preItem.video.url)
|
||||||
|
throw IllegalStateException("Mismatch item [${preItem.video.url}]");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "TEST: Index Lookup speed ${fetchFromIndex}ms");
|
||||||
|
|
||||||
|
val page1 = _historyDBStore.getPage(0, 20);
|
||||||
|
val page2 = _historyDBStore.getPage(1, 20);
|
||||||
|
val page3 = _historyDBStore.getPage(2, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -117,7 +117,7 @@ class StateNotifications {
|
|||||||
.setContentText("${content.name}")
|
.setContentText("${content.name}")
|
||||||
.setSubText(content.datetime?.toHumanNowDiffStringMinDay())
|
.setSubText(content.datetime?.toHumanNowDiffStringMinDay())
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url),
|
.setContentIntent(PendingIntent.getActivity(context, content.hashCode(), MainActivity.getVideoIntent(context, content.url),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||||
.setChannelId(notificationChannel.id);
|
.setChannelId(notificationChannel.id);
|
||||||
if(thumbnail != null) {
|
if(thumbnail != null) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import kotlinx.coroutines.*
|
|||||||
import okhttp3.internal.concat
|
import okhttp3.internal.concat
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
|
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
|
||||||
|
import kotlin.streams.asSequence
|
||||||
import kotlin.streams.toList
|
import kotlin.streams.toList
|
||||||
|
|
||||||
/***
|
/***
|
||||||
@@ -389,6 +390,7 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
return@map homeResult;
|
return@map homeResult;
|
||||||
}
|
}
|
||||||
|
.asSequence()
|
||||||
.toList()
|
.toList()
|
||||||
.associateWith { 1f };
|
.associateWith { 1f };
|
||||||
|
|
||||||
@@ -709,6 +711,7 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
return@map results;
|
return@map results;
|
||||||
}
|
}
|
||||||
|
.asSequence()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.states
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||||
@@ -11,6 +12,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||||
@@ -19,6 +21,8 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.HistoryVideo
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@@ -26,6 +30,8 @@ import kotlinx.serialization.json.Json
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Used to maintain playlists
|
* Used to maintain playlists
|
||||||
@@ -39,26 +45,17 @@ class StatePlaylists {
|
|||||||
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
|
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
|
||||||
})
|
})
|
||||||
.load();
|
.load();
|
||||||
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
|
|
||||||
.withRestore(object: ReconstructStore<HistoryVideo>() {
|
|
||||||
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
|
|
||||||
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
|
|
||||||
= HistoryVideo.fromReconString(backup, null);
|
|
||||||
})
|
|
||||||
.load();
|
|
||||||
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
|
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
|
||||||
.withRestore(PlaylistBackup())
|
.withRestore(PlaylistBackup())
|
||||||
.load();
|
.load();
|
||||||
|
|
||||||
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
|
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
|
||||||
|
|
||||||
var onHistoricVideoChanged = Event2<IPlatformVideo, Long>();
|
|
||||||
val onWatchLaterChanged = Event0();
|
val onWatchLaterChanged = Event0();
|
||||||
|
|
||||||
fun toMigrateCheck(): List<ManagedStore<*>> {
|
fun toMigrateCheck(): List<ManagedStore<*>> {
|
||||||
return listOf(playlistStore, _watchlistStore, _historyStore);
|
return listOf(playlistStore, _watchlistStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getWatchLater() : List<SerializedPlatformVideo> {
|
fun getWatchLater() : List<SerializedPlatformVideo> {
|
||||||
synchronized(_watchlistStore) {
|
synchronized(_watchlistStore) {
|
||||||
return _watchlistStore.getItems();
|
return _watchlistStore.getItems();
|
||||||
@@ -99,6 +96,7 @@ class StatePlaylists {
|
|||||||
return playlistStore.findItem { it.id == id };
|
return playlistStore.findItem { it.id == id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun didPlay(playlistId: String) {
|
fun didPlay(playlistId: String) {
|
||||||
val playlist = getPlaylist(playlistId);
|
val playlist = getPlaylist(playlistId);
|
||||||
if(playlist != null) {
|
if(playlist != null) {
|
||||||
@@ -107,66 +105,6 @@ class StatePlaylists {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getHistoryPosition(url: String): Long {
|
|
||||||
val histVideo = _historyStore.findItem { it.video.url == url };
|
|
||||||
if(histVideo != null)
|
|
||||||
return histVideo.position;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long {
|
|
||||||
val pos = if(position < 0) 0 else position;
|
|
||||||
val historyVideo = _historyStore.findItem { it.video.url == video.url };
|
|
||||||
if (historyVideo != null) {
|
|
||||||
val positionBefore = historyVideo.position;
|
|
||||||
if (updateExisting) {
|
|
||||||
var shouldUpdate = false;
|
|
||||||
if (positionBefore < 30) {
|
|
||||||
shouldUpdate = true;
|
|
||||||
} else {
|
|
||||||
if (position > 30) {
|
|
||||||
shouldUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUpdate) {
|
|
||||||
|
|
||||||
//A unrecovered item
|
|
||||||
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
|
|
||||||
historyVideo.video = SerializedPlatformVideo.fromVideo(video);
|
|
||||||
|
|
||||||
historyVideo.position = pos;
|
|
||||||
historyVideo.date = OffsetDateTime.now();
|
|
||||||
_historyStore.saveAsync(historyVideo);
|
|
||||||
onHistoricVideoChanged.emit(video, pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return positionBefore;
|
|
||||||
} else {
|
|
||||||
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now());
|
|
||||||
_historyStore.saveAsync(newHistItem);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getHistory() : List<HistoryVideo> {
|
|
||||||
return _historyStore.getItems().sortedByDescending { it.date };
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeHistory(url: String) {
|
|
||||||
val hist = _historyStore.findItem { it.video.url == url };
|
|
||||||
if(hist != null)
|
|
||||||
_historyStore.delete(hist);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeHistoryRange(minutesToDelete: Long) {
|
|
||||||
val now = OffsetDateTime.now();
|
|
||||||
val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete };
|
|
||||||
|
|
||||||
for(item in toDelete)
|
|
||||||
_historyStore.delete(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist {
|
suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist {
|
||||||
val channel = StatePlatform.instance.getChannel(channelUrl).await();
|
val channel = StatePlatform.instance.getChannel(channelUrl).await();
|
||||||
return createPlaylistFromChannel(channel, onPage);
|
return createPlaylistFromChannel(channel, onPage);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -11,17 +12,12 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
|
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.api.media.structures.PlaceholderPager
|
|
||||||
import com.futo.platformplayer.api.media.structures.RefreshChronoContentPager
|
|
||||||
import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager
|
|
||||||
import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
|
|
||||||
import com.futo.platformplayer.awaitFirstDeferred
|
import com.futo.platformplayer.awaitFirstDeferred
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||||
@@ -38,11 +34,11 @@ import kotlinx.coroutines.Deferred
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import kotlin.Exception
|
||||||
|
|
||||||
class StatePolycentric {
|
class StatePolycentric {
|
||||||
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
|
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
|
||||||
@@ -50,23 +46,55 @@ class StatePolycentric {
|
|||||||
var processHandle: ProcessHandle? = null; private set;
|
var processHandle: ProcessHandle? = null; private set;
|
||||||
private var _likeDislikeMap = hashMapOf<String, LikeDislikeEntry>()
|
private var _likeDislikeMap = hashMapOf<String, LikeDislikeEntry>()
|
||||||
private val _activeProcessHandle = FragmentedStorage.get<StringStorage>("activeProcessHandle");
|
private val _activeProcessHandle = FragmentedStorage.get<StringStorage>("activeProcessHandle");
|
||||||
|
private var _transientEnabled = true
|
||||||
|
val enabled get() = _transientEnabled && Settings.instance.other.polycentricEnabled
|
||||||
|
|
||||||
fun load(context: Context) {
|
fun load(context: Context) {
|
||||||
val db = SqlLiteDbHelper(context);
|
if (!enabled) {
|
||||||
Store.initializeSqlLiteStore(db);
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val activeProcessHandleString = _activeProcessHandle.value;
|
try {
|
||||||
if (activeProcessHandleString.isNotEmpty()) {
|
val db = SqlLiteDbHelper(context);
|
||||||
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
|
Store.initializeSqlLiteStore(db);
|
||||||
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
|
|
||||||
|
val activeProcessHandleString = _activeProcessHandle.value;
|
||||||
|
if (activeProcessHandleString.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
|
||||||
|
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
db.upgradeOldSecrets(db.writableDatabase);
|
||||||
|
|
||||||
|
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
|
||||||
|
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
|
||||||
|
|
||||||
|
Log.i(TAG, "Failed to initialize Polycentric.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
_transientEnabled = false
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
|
||||||
|
Log.i(TAG, "Failed to initialize Polycentric.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureEnabled() {
|
||||||
|
if (!enabled) {
|
||||||
|
throw Exception("Polycentric is disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getProcessHandles(): List<ProcessHandle> {
|
fun getProcessHandles(): List<ProcessHandle> {
|
||||||
|
if (!enabled) {
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
|
||||||
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); };
|
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setProcessHandle(processHandle: ProcessHandle?) {
|
fun setProcessHandle(processHandle: ProcessHandle?) {
|
||||||
|
ensureEnabled()
|
||||||
this.processHandle = processHandle;
|
this.processHandle = processHandle;
|
||||||
|
|
||||||
if (processHandle != null) {
|
if (processHandle != null) {
|
||||||
@@ -96,20 +124,34 @@ class StatePolycentric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateLikeMap(ref: Protocol.Reference, hasLiked: Boolean, hasDisliked: Boolean) {
|
fun updateLikeMap(ref: Protocol.Reference, hasLiked: Boolean, hasDisliked: Boolean) {
|
||||||
|
ensureEnabled()
|
||||||
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
|
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasDisliked(ref: Protocol.Reference): Boolean {
|
fun hasDisliked(ref: Protocol.Reference): Boolean {
|
||||||
|
if (!enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
|
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
|
||||||
return entry.hasDisliked;
|
return entry.hasDisliked;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasLiked(ref: Protocol.Reference): Boolean {
|
fun hasLiked(ref: Protocol.Reference): Boolean {
|
||||||
|
if (!enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
|
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
|
||||||
return entry.hasLiked;
|
return entry.hasLiked;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requireLogin(context: Context, text: String, action: (processHandle: ProcessHandle) -> Unit) {
|
fun requireLogin(context: Context, text: String, action: (processHandle: ProcessHandle) -> Unit) {
|
||||||
|
if (!enabled) {
|
||||||
|
UIDialogs.toast(context, "Polycentric is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val p = processHandle;
|
val p = processHandle;
|
||||||
if (p == null) {
|
if (p == null) {
|
||||||
Logger.i(TAG, "requireLogin preventPictureInPicture.emit()");
|
Logger.i(TAG, "requireLogin preventPictureInPicture.emit()");
|
||||||
@@ -127,24 +169,10 @@ class StatePolycentric {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
|
||||||
//TODO: Currently abusing subscription concurrency for parallelism
|
|
||||||
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
|
|
||||||
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
|
|
||||||
val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
|
|
||||||
if (!StatePlatform.instance.hasEnabledChannelClient(url)) {
|
|
||||||
return@mapNotNull null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, ignorePlugins);
|
|
||||||
}.toTypedArray();
|
|
||||||
|
|
||||||
val pager = MultiChronoContentPager(pagers);
|
|
||||||
pager.initialize();
|
|
||||||
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelUrls(url: String, channelId: PlatformID? = null, cacheOnly: Boolean = false): List<String> {
|
fun getChannelUrls(url: String, channelId: PlatformID? = null, cacheOnly: Boolean = false): List<String> {
|
||||||
|
if (!enabled) {
|
||||||
|
return listOf(url);
|
||||||
|
}
|
||||||
|
|
||||||
var polycentricProfile: PolycentricProfile? = null;
|
var polycentricProfile: PolycentricProfile? = null;
|
||||||
try {
|
try {
|
||||||
@@ -172,7 +200,10 @@ class StatePolycentric {
|
|||||||
else
|
else
|
||||||
return listOf(url);
|
return listOf(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
|
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
|
||||||
|
ensureEnabled()
|
||||||
|
|
||||||
//TODO: Currently abusing subscription concurrency for parallelism
|
//TODO: Currently abusing subscription concurrency for parallelism
|
||||||
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
|
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
|
||||||
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
|
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
|
||||||
@@ -212,13 +243,78 @@ class StatePolycentric {
|
|||||||
StatePlatform.instance.getEnabledClients().map { it.id }
|
StatePlatform.instance.getEnabledClients().map { it.id }
|
||||||
);*/
|
);*/
|
||||||
}
|
}
|
||||||
suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> {
|
fun getSystemComments(context: Context, system: PublicKey): List<IPlatformComment> {
|
||||||
return withContext(Dispatchers.IO) {
|
if (!enabled) {
|
||||||
getChannelContent(this, profile) ?: EmptyPager();
|
return listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dp_25 = 25.dp(context.resources)
|
||||||
|
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
||||||
|
val author = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable())
|
||||||
|
val posts = arrayListOf<PolycentricPlatformComment>()
|
||||||
|
Store.instance.enumerateSignedEvents(system, ContentType.POST) { se ->
|
||||||
|
val ev = se.event
|
||||||
|
val post = Protocol.Post.parseFrom(ev.content)
|
||||||
|
|
||||||
|
posts.add(PolycentricPlatformComment(
|
||||||
|
contextUrl = author,
|
||||||
|
author = PlatformAuthorLink(
|
||||||
|
id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
|
||||||
|
name = systemState.username,
|
||||||
|
url = author,
|
||||||
|
thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
|
||||||
|
subscribers = null
|
||||||
|
),
|
||||||
|
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
||||||
|
rating = RatingLikeDislikes(0, 0),
|
||||||
|
date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
|
||||||
|
replyCount = 0,
|
||||||
|
eventPointer = se.toPointer()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LikesDislikesReplies(
|
||||||
|
var likes: Long,
|
||||||
|
var dislikes: Long,
|
||||||
|
var replyCount: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
|
||||||
|
ensureEnabled()
|
||||||
|
|
||||||
|
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
|
||||||
|
null,
|
||||||
|
listOf(
|
||||||
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
|
.setFromType(ContentType.OPINION.value)
|
||||||
|
.setValue(ByteString.copyFrom(Opinion.like.data))
|
||||||
|
.build(),
|
||||||
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
|
.setFromType(ContentType.OPINION.value)
|
||||||
|
.setValue(ByteString.copyFrom(Opinion.dislike.data))
|
||||||
|
.build()
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
Protocol.QueryReferencesRequestCountReferences.newBuilder()
|
||||||
|
.setFromType(ContentType.POST.value)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
val likes = response.countsList[0];
|
||||||
|
val dislikes = response.countsList[1];
|
||||||
|
val replyCount = response.countsList[2];
|
||||||
|
return LikesDislikesReplies(likes, dislikes, replyCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> {
|
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> {
|
||||||
|
if (!enabled) {
|
||||||
|
return EmptyPager()
|
||||||
|
}
|
||||||
|
|
||||||
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
|
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
|
||||||
Protocol.QueryReferencesRequestEvents.newBuilder()
|
Protocol.QueryReferencesRequestEvents.newBuilder()
|
||||||
.setFromType(ContentType.POST.value)
|
.setFromType(ContentType.POST.value)
|
||||||
@@ -284,7 +380,7 @@ class StatePolycentric {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<IPlatformComment> {
|
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<PolycentricPlatformComment> {
|
||||||
return response.itemsList.mapNotNull {
|
return response.itemsList.mapNotNull {
|
||||||
val sev = SignedEvent.fromProto(it.event);
|
val sev = SignedEvent.fromProto(it.event);
|
||||||
val ev = sev.event;
|
val ev = sev.event;
|
||||||
@@ -294,7 +390,6 @@ class StatePolycentric {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val post = Protocol.Post.parseFrom(ev.content);
|
val post = Protocol.Post.parseFrom(ev.content);
|
||||||
val id = ev.system.toProto().key.toByteArray().toBase64();
|
|
||||||
val likes = it.countsList[0];
|
val likes = it.countsList[0];
|
||||||
val dislikes = it.countsList[1];
|
val dislikes = it.countsList[1];
|
||||||
val replies = it.countsList[2];
|
val replies = it.countsList[2];
|
||||||
@@ -338,7 +433,7 @@ class StatePolycentric {
|
|||||||
rating = RatingLikeDislikes(likes, dislikes),
|
rating = RatingLikeDislikes(likes, dislikes),
|
||||||
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
|
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
|
||||||
replyCount = replies.toInt(),
|
replyCount = replies.toInt(),
|
||||||
reference = sev.toPointer().toReference()
|
eventPointer = sev.toPointer()
|
||||||
);
|
);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
return@mapNotNull null;
|
return@mapNotNull null;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.structures.*
|
import com.futo.platformplayer.api.media.structures.*
|
||||||
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
|
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
@@ -39,6 +38,7 @@ import java.util.concurrent.ForkJoinTask
|
|||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import kotlin.streams.asSequence
|
||||||
import kotlin.streams.toList
|
import kotlin.streams.toList
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
@@ -259,7 +259,9 @@ class StateSubscriptions {
|
|||||||
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
|
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
|
||||||
else
|
else
|
||||||
Pair(it, listOf(it.channel.url));
|
Pair(it, listOf(it.channel.url));
|
||||||
}.toList().associate { it };
|
}.asSequence()
|
||||||
|
.toList()
|
||||||
|
.associate { it };
|
||||||
|
|
||||||
val result = algo.getSubscriptions(subUrls);
|
val result = algo.getSubscriptions(subUrls);
|
||||||
return Pair(result.pager, result.exceptions);
|
return Pair(result.pager, result.exceptions);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.futo.platformplayer.stores
|
|||||||
|
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBIndex
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
|
||||||
|
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class ColumnIndex(val name: String = ColumnInfo.INHERIT_FIELD_NAME)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class ColumnOrdered(val priority: Int, val descending: Boolean = false);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Dao
|
||||||
|
class ManagedDBContext<T, I: ManagedDBIndex<T>> {
|
||||||
|
|
||||||
|
fun get(id: Int): I;
|
||||||
|
fun gets(vararg id: Int): List<I>;
|
||||||
|
fun getAll(): List<I>;
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(index: I);
|
||||||
|
@Insert
|
||||||
|
fun insertAll(vararg indexes: I)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(index: I);
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
fun delete(index: I);
|
||||||
|
}*/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Update
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ManagedDBContextPaged<T, I: ManagedDBIndex<T>> {
|
||||||
|
fun getPaged(page: Int, pageSize: Int): List<I>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RawQuery
|
||||||
|
import androidx.room.Update
|
||||||
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ManagedDBDAOBase<T, I: ManagedDBIndex<T>> {
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
fun get(query: SupportSQLiteQuery): I;
|
||||||
|
@RawQuery
|
||||||
|
fun getNullable(query: SupportSQLiteQuery): I?;
|
||||||
|
@RawQuery
|
||||||
|
fun getMultiple(query: SupportSQLiteQuery): List<I>;
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
fun action(query: SupportSQLiteQuery): Int
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(index: I): Long;
|
||||||
|
@Insert
|
||||||
|
fun insertAll(vararg indexes: I)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(index: I);
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
fun delete(index: I);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
abstract class ManagedDBDatabase<T, I: ManagedDBIndex<T>, D: ManagedDBDAOBase<T, I>>: RoomDatabase() {
|
||||||
|
abstract fun base(): D;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
|
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
|
||||||
|
abstract class ManagedDBDescriptor<T, I: ManagedDBIndex<T>, D: ManagedDBDatabase<T, I, DA>, DA: ManagedDBDAOBase<T, I>> {
|
||||||
|
abstract val table_name: String;
|
||||||
|
abstract fun dbClass(): KClass<D>;
|
||||||
|
abstract fun create(obj: T): I;
|
||||||
|
|
||||||
|
abstract fun indexClass(): KClass<I>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
package com.futo.platformplayer.stores.db
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
class ManagedDBIndex {
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Ignore
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
|
|
||||||
|
open class ManagedDBIndex<T> {
|
||||||
|
@ColumnIndex
|
||||||
|
@PrimaryKey(true)
|
||||||
|
open var id: Long? = null;
|
||||||
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||||
|
var serialized: ByteArray? = null;
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
private var _obj: T? = null;
|
||||||
|
@Ignore
|
||||||
|
var isCorrupted: Boolean = false;
|
||||||
|
|
||||||
|
@get:Ignore
|
||||||
|
val obj: T get() = _obj ?: throw IllegalStateException("Attempted to access serialized object on a index-only instance");
|
||||||
|
|
||||||
|
@get:Ignore
|
||||||
|
val objOrNull: T? get() = _obj;
|
||||||
|
|
||||||
|
fun setInstance(obj: T) {
|
||||||
|
this._obj = obj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Update
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ManagedDBIndexOnly<T, I: ManagedDBIndex<T>> {
|
||||||
|
fun getIndex(): List<I>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,457 @@
|
|||||||
package com.futo.platformplayer.stores.db
|
package com.futo.platformplayer.stores.db
|
||||||
|
|
||||||
class ManagedDBStore {
|
import android.content.Context
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.assume
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
|
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
import kotlin.reflect.KType
|
||||||
|
import kotlin.reflect.full.findAnnotation
|
||||||
|
import kotlin.reflect.full.hasAnnotation
|
||||||
|
import kotlin.reflect.full.memberProperties
|
||||||
|
import kotlin.reflect.jvm.javaField
|
||||||
|
|
||||||
|
class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA: ManagedDBDAOBase<T, I>> {
|
||||||
|
private val _class: KType;
|
||||||
|
private val _name: String;
|
||||||
|
private val _serializer: StoreSerializer<T>;
|
||||||
|
|
||||||
|
private var _db: ManagedDBDatabase<T, I, *>? = null;
|
||||||
|
private var _dbDaoBase: ManagedDBDAOBase<T, I>? = null;
|
||||||
|
val dbDaoBase: ManagedDBDAOBase<T, I> get() = _dbDaoBase ?: throw IllegalStateException("Not initialized db [${name}]");
|
||||||
|
|
||||||
|
val descriptor: ManagedDBDescriptor<T, I, D, DA>;
|
||||||
|
|
||||||
|
private val _columnInfo: List<ColumnMetadata>;
|
||||||
|
|
||||||
|
private val _sqlGet: (Long)-> SimpleSQLiteQuery;
|
||||||
|
private val _sqlGetIndex: (Long)-> SimpleSQLiteQuery;
|
||||||
|
private val _sqlGetAll: (LongArray)-> SimpleSQLiteQuery;
|
||||||
|
private val _sqlAll: SimpleSQLiteQuery;
|
||||||
|
private val _sqlCount: SimpleSQLiteQuery;
|
||||||
|
private val _sqlDeleteAll: SimpleSQLiteQuery;
|
||||||
|
private val _sqlDeleteById: (Long) -> SimpleSQLiteQuery;
|
||||||
|
private var _sqlIndexed: SimpleSQLiteQuery? = null;
|
||||||
|
private var _sqlPage: ((Int, Int) -> SimpleSQLiteQuery)? = null;
|
||||||
|
|
||||||
|
val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName;
|
||||||
|
|
||||||
|
val name: String;
|
||||||
|
|
||||||
|
private val _indexes: ArrayList<IndexDescriptor<I>> = arrayListOf();
|
||||||
|
private val _indexCollection = ConcurrentHashMap<Long, I>();
|
||||||
|
|
||||||
|
private var _withUnique: Pair<(I)->Any, ConcurrentMap<Any, I>>? = null;
|
||||||
|
private val _orderSQL: String?;
|
||||||
|
|
||||||
|
constructor(name: String, descriptor: ManagedDBDescriptor<T, I, D, DA>, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) {
|
||||||
|
this.descriptor = descriptor;
|
||||||
|
_name = name;
|
||||||
|
this.name = niceName ?: name.let {
|
||||||
|
if(it.isNotEmpty())
|
||||||
|
return@let it[0].uppercase() + it.substring(1);
|
||||||
|
return@let name;
|
||||||
|
};
|
||||||
|
_serializer = serializer;
|
||||||
|
_class = clazz;
|
||||||
|
_columnInfo = this.descriptor.indexClass().memberProperties
|
||||||
|
.filter { it.hasAnnotation<ColumnIndex>() && it.name != "serialized" }
|
||||||
|
.map { ColumnMetadata(it.javaField!!, it.findAnnotation<ColumnIndex>()!!, it.findAnnotation<ColumnOrdered>()) };
|
||||||
|
|
||||||
|
val indexColumnNames = _columnInfo.map { it.name };
|
||||||
|
|
||||||
|
val orderedColumns = _columnInfo.filter { it.ordered != null }.sortedBy { it.ordered!!.priority };
|
||||||
|
_orderSQL = if(orderedColumns.size > 0)
|
||||||
|
" ORDER BY " + orderedColumns.map { "${it.name} ${if(it.ordered!!.descending) "DESC" else "ASC"}" }.joinToString(", ");
|
||||||
|
else "";
|
||||||
|
|
||||||
|
_sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} WHERE id = ?", arrayOf(it)) };
|
||||||
|
_sqlGetIndex = { SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${this.descriptor.table_name} WHERE id = ?", arrayOf(it)) };
|
||||||
|
_sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} WHERE id IN (?)", arrayOf(it)) };
|
||||||
|
_sqlAll = SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} ${_orderSQL}");
|
||||||
|
_sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${this.descriptor.table_name}");
|
||||||
|
_sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${this.descriptor.table_name}");
|
||||||
|
_sqlDeleteById = { id -> SimpleSQLiteQuery("DELETE FROM ${this.descriptor.table_name} WHERE id = :id", arrayOf(id)) };
|
||||||
|
_sqlIndexed = SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${this.descriptor.table_name}");
|
||||||
|
|
||||||
|
if(orderedColumns.size > 0) {
|
||||||
|
_sqlPage = { page, length ->
|
||||||
|
SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} ${_orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap<Any, I>, allowChange: Boolean = false, withUnique: Boolean = false): ManagedDBStore<I, T, D, DA> {
|
||||||
|
if(_sqlIndexed == null)
|
||||||
|
throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented");
|
||||||
|
_indexes.add(IndexDescriptor(keySelector, indexContainer, allowChange));
|
||||||
|
|
||||||
|
if(withUnique)
|
||||||
|
withUnique(keySelector, indexContainer);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
fun withUnique(keySelector: (I)->Any, indexContainer: ConcurrentMap<Any, I>): ManagedDBStore<I, T, D, DA> {
|
||||||
|
if(_withUnique != null)
|
||||||
|
throw IllegalStateException("Only 1 unique property is allowed");
|
||||||
|
_withUnique = Pair(keySelector, indexContainer);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context? = null, inMemory: Boolean = false): ManagedDBStore<I, T, D, DA> {
|
||||||
|
_db = (if(!inMemory)
|
||||||
|
Room.databaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java, _name)
|
||||||
|
else
|
||||||
|
Room.inMemoryDatabaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java))
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
_dbDaoBase = _db!!.base() as ManagedDBDAOBase<T, I>;
|
||||||
|
if(_indexes.any()) {
|
||||||
|
val allItems = _dbDaoBase!!.getMultiple(_sqlIndexed!!);
|
||||||
|
for(index in _indexes)
|
||||||
|
index.collection.putAll(allItems.associateBy(index.keySelector));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
fun shutdown() {
|
||||||
|
val db = _db;
|
||||||
|
_db = null;
|
||||||
|
_dbDaoBase = null;
|
||||||
|
db?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUnique(obj: I): I? {
|
||||||
|
if(_withUnique == null)
|
||||||
|
throw IllegalStateException("Unique is not configured for ${name}");
|
||||||
|
val key = _withUnique!!.first.invoke(obj);
|
||||||
|
return _withUnique!!.second[key];
|
||||||
|
}
|
||||||
|
fun isUnique(obj: I): Boolean {
|
||||||
|
if(_withUnique == null)
|
||||||
|
throw IllegalStateException("Unique is not configured for ${name}");
|
||||||
|
val key = _withUnique!!.first.invoke(obj);
|
||||||
|
return !_withUnique!!.second.containsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun count(): Int {
|
||||||
|
return dbDaoBase.action(_sqlCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insert(obj: T): Long {
|
||||||
|
val newIndex = descriptor.create(obj);
|
||||||
|
|
||||||
|
if(_withUnique != null) {
|
||||||
|
val unique = getUnique(newIndex);
|
||||||
|
if (unique != null)
|
||||||
|
return unique.id!!;
|
||||||
|
}
|
||||||
|
|
||||||
|
newIndex.serialized = serialize(obj);
|
||||||
|
newIndex.id = dbDaoBase.insert(newIndex);
|
||||||
|
newIndex.serialized = null;
|
||||||
|
|
||||||
|
|
||||||
|
if(!_indexes.isEmpty()) {
|
||||||
|
for (index in _indexes) {
|
||||||
|
val key = index.keySelector(newIndex);
|
||||||
|
index.collection.put(key, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newIndex.id!!;
|
||||||
|
}
|
||||||
|
fun update(id: Long, obj: T) {
|
||||||
|
val existing = if(_indexes.any { it.checkChange }) _dbDaoBase!!.getNullable(_sqlGetIndex(id)) else null
|
||||||
|
|
||||||
|
val newIndex = descriptor.create(obj);
|
||||||
|
newIndex.id = id;
|
||||||
|
newIndex.serialized = serialize(obj);
|
||||||
|
dbDaoBase.update(newIndex);
|
||||||
|
newIndex.serialized = null;
|
||||||
|
|
||||||
|
if(!_indexes.isEmpty()) {
|
||||||
|
for (index in _indexes) {
|
||||||
|
val key = index.keySelector(newIndex);
|
||||||
|
if(index.checkChange && existing != null) {
|
||||||
|
val keyExisting = index.keySelector(existing);
|
||||||
|
if(keyExisting != key)
|
||||||
|
index.collection.remove(keyExisting);
|
||||||
|
}
|
||||||
|
index.collection.put(key, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllIndexes(): List<I> {
|
||||||
|
if(_sqlIndexed == null)
|
||||||
|
throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented");
|
||||||
|
return dbDaoBase.getMultiple(_sqlIndexed!!);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllObjects(): List<T> = convertObjects(getAll());
|
||||||
|
fun getAll(): List<I> {
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(_sqlAll));
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getObject(id: Long) = get(id).obj!!;
|
||||||
|
fun get(id: Long): I {
|
||||||
|
return deserializeIndex(dbDaoBase.get(_sqlGet(id)));
|
||||||
|
}
|
||||||
|
fun getOrNull(id: Long): I? {
|
||||||
|
val result = dbDaoBase.getNullable(_sqlGet(id));
|
||||||
|
if(result == null)
|
||||||
|
return null;
|
||||||
|
return deserializeIndex(result);
|
||||||
|
}
|
||||||
|
fun getIndexOnlyOrNull(id: Long): I? {
|
||||||
|
return dbDaoBase.get(_sqlGetIndex(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllObjects(vararg id: Long): List<T> = getAll(*id).map { it.obj!! };
|
||||||
|
fun getAll(vararg id: Long): List<I> {
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(_sqlGetAll(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fun query(field: KProperty<*>, obj: Any): List<I> = query(validateFieldName(field), obj);
|
||||||
|
fun query(field: String, obj: Any): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun queryLike(field: KProperty<*>, obj: String): List<I> = queryLike(validateFieldName(field), obj);
|
||||||
|
fun queryLike(field: String, obj: String): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun queryGreater(field: KProperty<*>, obj: Any): List<I> = queryGreater(validateFieldName(field), obj);
|
||||||
|
fun queryGreater(field: String, obj: Any): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} > ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun querySmaller(field: KProperty<*>, obj: Any): List<I> = querySmaller(validateFieldName(field), obj);
|
||||||
|
fun querySmaller(field: String, obj: Any): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} < ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun queryBetween(field: KProperty<*>, greaterThan: Any, smallerThan: Any): List<I> = queryBetween(validateFieldName(field), greaterThan, smallerThan);
|
||||||
|
fun queryBetween(field: String, greaterThan: Any, smallerThan: Any): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} > ? AND ${field} < ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(greaterThan, smallerThan));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
//Query Pages
|
||||||
|
fun queryPage(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List<I> = queryPage(validateFieldName(field), obj, page, pageSize);
|
||||||
|
fun queryPage(field: String, obj: Any, page: Int, pageSize: Int): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ? ${_orderSQL} LIMIT ? OFFSET ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun queryLikePage(field: KProperty<*>, obj: String, page: Int, pageSize: Int): List<I> = queryLikePage(validateFieldName(field), obj, page, pageSize);
|
||||||
|
fun queryLikePage(field: String, obj: String, page: Int, pageSize: Int): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize));
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun queryLikeObjectPage(field: String, obj: String, page: Int, pageSize: Int): List<T> {
|
||||||
|
return convertObjects(queryLikePage(field, obj, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//Query Page Objects
|
||||||
|
fun queryPageObjects(field: String, obj: Any, page: Int, pageSize: Int): List<T> = convertObjects(queryPage(field, obj, page, pageSize));
|
||||||
|
fun queryPageObjects(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List<T> = queryPageObjects(validateFieldName(field), obj, page, pageSize);
|
||||||
|
|
||||||
|
//Query Pager
|
||||||
|
fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager<I> = queryPager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryPager(field: String, obj: Any, pageSize: Int): IPager<I> {
|
||||||
|
return AdhocPager({
|
||||||
|
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||||
|
queryPage(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun queryInPage(field: KProperty<*>, obj: List<String>, page: Int, pageSize: Int): List<I> = queryInPage(validateFieldName(field), obj, page, pageSize);
|
||||||
|
fun queryInPage(field: String, obj: List<String>, page: Int, pageSize: Int): List<I> {
|
||||||
|
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} IN (${obj.joinToString(",") { "?" }}) ${_orderSQL} LIMIT ? OFFSET ?";
|
||||||
|
val query = SimpleSQLiteQuery(queryStr, (obj + arrayOf(pageSize, page * pageSize)).toTypedArray());
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun queryInObjectPage(field: String, obj: List<String>, page: Int, pageSize: Int): List<T> {
|
||||||
|
return convertObjects(queryInPage(field, obj, page, pageSize));
|
||||||
|
}
|
||||||
|
fun queryInPager(field: KProperty<*>, obj: List<String>, pageSize: Int): IPager<I> = queryInPager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryInPager(field: String, obj: List<String>, pageSize: Int): IPager<I> {
|
||||||
|
return AdhocPager({
|
||||||
|
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||||
|
queryInPage(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fun queryInObjectPager(field: KProperty<*>, obj: List<String>, pageSize: Int): IPager<T> = queryInObjectPager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryInObjectPager(field: String, obj: List<String>, pageSize: Int): IPager<T> {
|
||||||
|
return AdhocPager({
|
||||||
|
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||||
|
queryInObjectPage(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <X> queryInPager(field: KProperty<*>, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> = queryInPager(validateFieldName(field), obj, pageSize, convert);
|
||||||
|
fun <X> queryInPager(field: String, obj: List<String>, pageSize: Int, convert: (I)->X): IPager<X> {
|
||||||
|
return AdhocPager({
|
||||||
|
queryInPage(field, obj, it - 1, pageSize).map(convert);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fun queryLikePager(field: KProperty<*>, obj: String, pageSize: Int): IPager<I> = queryLikePager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryLikePager(field: String, obj: String, pageSize: Int): IPager<I> {
|
||||||
|
return AdhocPager({
|
||||||
|
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||||
|
queryLikePage(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fun queryLikeObjectPager(field: KProperty<*>, obj: String, pageSize: Int): IPager<T> = queryLikeObjectPager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryLikeObjectPager(field: String, obj: String, pageSize: Int): IPager<T> {
|
||||||
|
return AdhocPager({
|
||||||
|
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||||
|
queryLikeObjectPage(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Query Pager with convert
|
||||||
|
fun <X> queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> = queryPager(validateFieldName(field), obj, pageSize, convert);
|
||||||
|
fun <X> queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> {
|
||||||
|
return AdhocPager({
|
||||||
|
queryPage(field, obj, it - 1, pageSize).map(convert);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Query Object Pager
|
||||||
|
fun queryObjectPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager<T> = queryObjectPager(validateFieldName(field), obj, pageSize);
|
||||||
|
fun queryObjectPager(field: String, obj: Any, pageSize: Int): IPager<T> {
|
||||||
|
return AdhocPager({
|
||||||
|
queryPageObjects(field, obj, it - 1, pageSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Page
|
||||||
|
fun getPage(page: Int, length: Int): List<I> {
|
||||||
|
if(_sqlPage == null)
|
||||||
|
throw IllegalStateException("DB Store [${name}] does not have ordered fields to provide pages");
|
||||||
|
val query = _sqlPage!!(page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}");
|
||||||
|
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||||
|
}
|
||||||
|
fun getPageObjects(page: Int, length: Int): List<T> = convertObjects(getPage(page, length));
|
||||||
|
|
||||||
|
fun getPager(pageLength: Int = 20): IPager<I> {
|
||||||
|
return AdhocPager({
|
||||||
|
getPage(it - 1, pageLength);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fun getObjectPager(pageLength: Int = 20): IPager<T> {
|
||||||
|
return AdhocPager({
|
||||||
|
getPageObjects(it - 1, pageLength);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(item: I) {
|
||||||
|
dbDaoBase.delete(item);
|
||||||
|
|
||||||
|
for(index in _indexes)
|
||||||
|
index.collection.remove(index.keySelector(item));
|
||||||
|
}
|
||||||
|
fun delete(id: Long) {
|
||||||
|
dbDaoBase.action(_sqlDeleteById(id));
|
||||||
|
|
||||||
|
for(index in _indexes)
|
||||||
|
index.collection.values.removeIf { it.id == id }
|
||||||
|
}
|
||||||
|
fun deleteAll() {
|
||||||
|
dbDaoBase.action(_sqlDeleteAll);
|
||||||
|
|
||||||
|
_indexCollection.clear();
|
||||||
|
for(index in _indexes)
|
||||||
|
index.collection.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fun convertObject(index: I): T? {
|
||||||
|
return index.objOrNull ?: deserializeIndex(index).obj;
|
||||||
|
}
|
||||||
|
fun convertObjects(indexes: List<I>): List<T> {
|
||||||
|
return indexes.mapNotNull { it.objOrNull ?: convertObject(it) };
|
||||||
|
}
|
||||||
|
fun deserializeIndex(index: I): I {
|
||||||
|
if(index.isCorrupted)
|
||||||
|
return index;
|
||||||
|
if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]");
|
||||||
|
try {
|
||||||
|
val obj = _serializer.deserialize(_class, index.serialized!!);
|
||||||
|
index.setInstance(obj);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
if(index.serialized != null && index.serialized!!.size > 0) {
|
||||||
|
Logger.w("ManagedDBStore", "Corrupted object in ${name} found [${index.id}], deleting due to ${ex.message}", ex);
|
||||||
|
index.isCorrupted = true;
|
||||||
|
delete(index.id!!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index.serialized = null;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
fun deserializeIndexes(indexes: List<I>): List<I> {
|
||||||
|
for(index in indexes)
|
||||||
|
deserializeIndex(index);
|
||||||
|
return indexes.filter { !it.isCorrupted }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serialize(obj: T): ByteArray {
|
||||||
|
return _serializer.serialize(_class, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun validateFieldName(prop: KProperty<*>): String {
|
||||||
|
val declaringClass = prop.javaField?.declaringClass;
|
||||||
|
if(declaringClass != descriptor.indexClass().java)
|
||||||
|
throw IllegalStateException("Cannot query by property [${prop.name}] from ${declaringClass?.simpleName} not part of ${descriptor.indexClass().simpleName}");
|
||||||
|
return prop.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun <reified T, I: ManagedDBIndex<T>, D: ManagedDBDatabase<T, I, DA>, DA: ManagedDBDAOBase<T, I>> create(name: String, descriptor: ManagedDBDescriptor<T, I, D, DA>, serializer: KSerializer<T>? = null)
|
||||||
|
= ManagedDBStore(name, descriptor, kotlin.reflect.typeOf<T>(), JsonStoreSerializer.create(serializer));
|
||||||
|
}
|
||||||
|
|
||||||
|
//Pair<(I)->Any, ConcurrentMap<Any, I>>
|
||||||
|
class IndexDescriptor<I>(
|
||||||
|
val keySelector: (I) -> Any,
|
||||||
|
val collection: ConcurrentMap<Any, I>,
|
||||||
|
val checkChange: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
class ColumnMetadata(
|
||||||
|
val field: Field,
|
||||||
|
val info: ColumnIndex,
|
||||||
|
val ordered: ColumnOrdered?
|
||||||
|
) {
|
||||||
|
val name get() = if(info.name == ColumnInfo.INHERIT_FIELD_NAME) field.name else info.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.futo.platformplayer.stores.db.types
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Ignore
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnIndex
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnOrdered
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDAOBase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDatabase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDescriptor
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBIndex
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import kotlin.reflect.KType
|
||||||
|
|
||||||
|
class DBHistory {
|
||||||
|
companion object {
|
||||||
|
const val TABLE_NAME = "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
//These classes solely exist for bounding generics for type erasure
|
||||||
|
@Dao
|
||||||
|
interface DBDAO: ManagedDBDAOBase<HistoryVideo, Index> {}
|
||||||
|
@Database(entities = [Index::class], version = 3)
|
||||||
|
abstract class DB: ManagedDBDatabase<HistoryVideo, Index, DBDAO>() {
|
||||||
|
abstract override fun base(): DBDAO;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Descriptor: ManagedDBDescriptor<HistoryVideo, Index, DB, DBDAO>() {
|
||||||
|
override val table_name: String = TABLE_NAME;
|
||||||
|
override fun create(obj: HistoryVideo): Index = Index(obj);
|
||||||
|
override fun dbClass(): KClass<DB> = DB::class;
|
||||||
|
override fun indexClass(): KClass<Index> = Index::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity(TABLE_NAME, indices = [
|
||||||
|
androidx.room.Index(value = ["url"]),
|
||||||
|
androidx.room.Index(value = ["name"]),
|
||||||
|
androidx.room.Index(value = ["datetime"], orders = [androidx.room.Index.Order.DESC])
|
||||||
|
])
|
||||||
|
class Index(): ManagedDBIndex<HistoryVideo>() {
|
||||||
|
@PrimaryKey(true)
|
||||||
|
@ColumnOrdered(1)
|
||||||
|
@ColumnIndex
|
||||||
|
override var id: Long? = null;
|
||||||
|
|
||||||
|
@ColumnIndex
|
||||||
|
var url: String = "";
|
||||||
|
@ColumnIndex
|
||||||
|
var position: Long = 0;
|
||||||
|
@ColumnIndex
|
||||||
|
@ColumnOrdered(0, true)
|
||||||
|
var datetime: Long = 0;
|
||||||
|
@ColumnIndex
|
||||||
|
var name: String = "";
|
||||||
|
|
||||||
|
constructor(historyVideo: HistoryVideo) : this() {
|
||||||
|
id = null;
|
||||||
|
serialized = null;
|
||||||
|
url = historyVideo.video.url;
|
||||||
|
position = historyVideo.position;
|
||||||
|
datetime = historyVideo.date.toEpochSecond();
|
||||||
|
name = historyVideo.video.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.futo.platformplayer.stores.db.types
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnIndex
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnOrdered
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDAOBase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDatabase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDescriptor
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBIndex
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
class DBSubscriptionCache {
|
||||||
|
companion object {
|
||||||
|
const val TABLE_NAME = "subscription_cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//These classes solely exist for bounding generics for type erasure
|
||||||
|
@Dao
|
||||||
|
interface DBDAO: ManagedDBDAOBase<SerializedPlatformContent, Index> {}
|
||||||
|
@Database(entities = [Index::class], version = 5)
|
||||||
|
abstract class DB: ManagedDBDatabase<SerializedPlatformContent, Index, DBDAO>() {
|
||||||
|
abstract override fun base(): DBDAO;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Descriptor: ManagedDBDescriptor<SerializedPlatformContent, Index, DB, DBDAO>() {
|
||||||
|
override val table_name: String = TABLE_NAME;
|
||||||
|
override fun create(obj: SerializedPlatformContent): Index = Index(obj);
|
||||||
|
override fun dbClass(): KClass<DB> = DB::class;
|
||||||
|
override fun indexClass(): KClass<Index> = Index::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity(TABLE_NAME, indices = [
|
||||||
|
androidx.room.Index(value = ["url"]),
|
||||||
|
androidx.room.Index(value = ["channelUrl"]),
|
||||||
|
androidx.room.Index(value = ["datetime"], orders = [androidx.room.Index.Order.DESC])
|
||||||
|
])
|
||||||
|
class Index: ManagedDBIndex<SerializedPlatformContent> {
|
||||||
|
@ColumnIndex
|
||||||
|
@PrimaryKey(true)
|
||||||
|
@ColumnOrdered(1)
|
||||||
|
override var id: Long? = null;
|
||||||
|
|
||||||
|
@ColumnIndex
|
||||||
|
var url: String? = null;
|
||||||
|
@ColumnIndex
|
||||||
|
var channelUrl: String? = null;
|
||||||
|
|
||||||
|
@ColumnIndex
|
||||||
|
@ColumnOrdered(0, true)
|
||||||
|
var datetime: Long? = null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
constructor(sCache: SerializedPlatformContent) {
|
||||||
|
id = null;
|
||||||
|
serialized = null;
|
||||||
|
url = sCache.url;
|
||||||
|
channelUrl = sCache.author.url;
|
||||||
|
datetime = sCache.datetime?.toEpochSecond();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-4
@@ -5,10 +5,10 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.toSafeFileName
|
import com.futo.platformplayer.toSafeFileName
|
||||||
@@ -27,13 +27,16 @@ class CachedSubscriptionAlgorithm(pageSize: Int = 150, scope: CoroutineScope, al
|
|||||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||||
val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet();
|
val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet();
|
||||||
|
|
||||||
val validStores = ChannelContentCache.instance._channelContents
|
/*
|
||||||
|
val validStores = StateCache.instance._channelContents
|
||||||
.filter { validSubIds.contains(it.key) }
|
.filter { validSubIds.contains(it.key) }
|
||||||
.map { it.value };
|
.map { it.value };*/
|
||||||
|
|
||||||
|
/*
|
||||||
val items = validStores.flatMap { it.getItems() }
|
val items = validStores.flatMap { it.getItems() }
|
||||||
.sortedByDescending { it.datetime };
|
.sortedByDescending { it.datetime };
|
||||||
|
*/
|
||||||
|
|
||||||
return Result(DedupContentPager(PlatformContentPager(items, Math.min(_pageSize, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }), listOf());
|
return Result(DedupContentPager(StateCache.instance.getChannelCachePager(subs.flatMap { it.value }.distinct()), StatePlatform.instance.getEnabledClients().map { it.id }), listOf());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -8,7 +8,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
|||||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
||||||
@@ -17,6 +16,7 @@ import com.futo.platformplayer.findNonRuntimeException
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
@@ -157,7 +157,7 @@ class SimpleSubscriptionAlgorithm(
|
|||||||
val time = measureTimeMillis {
|
val time = measureTimeMillis {
|
||||||
pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore);
|
pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore);
|
||||||
|
|
||||||
pager = ChannelContentCache.cachePagerResults(scope, pager!!) {
|
pager = StateCache.cachePagerResults(scope, pager!!) {
|
||||||
onNewCacheHit.emit(sub, it);
|
onNewCacheHit.emit(sub, it);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ class SimpleSubscriptionAlgorithm(
|
|||||||
throw channelEx;
|
throw channelEx;
|
||||||
else {
|
else {
|
||||||
Logger.i(StateSubscriptions.TAG, "Channel ${sub.channel.name} failed, substituting with cache");
|
Logger.i(StateSubscriptions.TAG, "Channel ${sub.channel.name} failed, substituting with cache");
|
||||||
pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url);
|
pager = StateCache.instance.getChannelCachePager(sub.channel.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager
|
|||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
||||||
@@ -21,6 +20,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -108,7 +108,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
||||||
val liveTasks = entry.value.filter { !it.task.fromCache };
|
val liveTasks = entry.value.filter { !it.task.fromCache };
|
||||||
val cachedTasks = entry.value.filter { it.task.fromCache };
|
val cachedTasks = entry.value.filter { it.task.fromCache };
|
||||||
val livePager = if(!liveTasks.isEmpty()) ChannelContentCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, {
|
val livePager = if(!liveTasks.isEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, {
|
||||||
onNewCacheHit.emit(sub!!, it);
|
onNewCacheHit.emit(sub!!, it);
|
||||||
}) else null;
|
}) else null;
|
||||||
val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null;
|
val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null;
|
||||||
@@ -142,7 +142,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||||||
return@submit SubscriptionTaskResult(task, null, null);
|
return@submit SubscriptionTaskResult(task, null, null);
|
||||||
else {
|
else {
|
||||||
cachedChannels.add(task.url);
|
cachedChannels.add(task.url);
|
||||||
return@submit SubscriptionTaskResult(task, ChannelContentCache.instance.getChannelCachePager(task.url), null);
|
return@submit SubscriptionTaskResult(task, StateCache.instance.getChannelCachePager(task.url), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||||||
throw channelEx;
|
throw channelEx;
|
||||||
else {
|
else {
|
||||||
Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache");
|
Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache");
|
||||||
pager = ChannelContentCache.instance.getChannelCachePager(task.sub.channel.url);
|
pager = StateCache.instance.getChannelCachePager(task.sub.channel.url);
|
||||||
taskEx = ex;
|
taskEx = ex;
|
||||||
return@submit SubscriptionTaskResult(task, pager, taskEx);
|
return@submit SubscriptionTaskResult(task, pager, taskEx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.futo.platformplayer.testing
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnIndex
|
||||||
|
import com.futo.platformplayer.stores.db.ColumnOrdered
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDAOBase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBDatabase
|
||||||
|
import com.futo.platformplayer.stores.db.ManagedDBIndex
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.Random
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DBTOs {
|
||||||
|
@Dao
|
||||||
|
interface DBDAO: ManagedDBDAOBase<TestObject, TestIndex> {}
|
||||||
|
@Database(entities = [TestIndex::class], version = 3)
|
||||||
|
abstract class DB: ManagedDBDatabase<TestObject, TestIndex, DBDAO>() {
|
||||||
|
abstract override fun base(): DBDAO;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Entity("testing")
|
||||||
|
class TestIndex(): ManagedDBIndex<TestObject>() {
|
||||||
|
|
||||||
|
@ColumnIndex
|
||||||
|
var someString: String = "";
|
||||||
|
@ColumnIndex
|
||||||
|
@ColumnOrdered(0)
|
||||||
|
var someNum: Int = 0;
|
||||||
|
|
||||||
|
constructor(obj: TestObject, customInt: Int? = null) : this() {
|
||||||
|
someString = obj.someStr;
|
||||||
|
someNum = customInt ?: obj.someNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Serializable
|
||||||
|
class TestObject {
|
||||||
|
var someStr = UUID.randomUUID().toString();
|
||||||
|
var someNum = random.nextInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val random = Random();
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.views
|
package com.futo.platformplayer.views
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -11,9 +12,10 @@ import android.widget.LinearLayout
|
|||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
|
||||||
class Loader : LinearLayout {
|
class LoaderView : LinearLayout {
|
||||||
private val _imageLoader: ImageView;
|
private val _imageLoader: ImageView;
|
||||||
private val _automatic: Boolean;
|
private val _automatic: Boolean;
|
||||||
|
private var _isWhite: Boolean;
|
||||||
private val _animatable: Animatable;
|
private val _animatable: Animatable;
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||||
@@ -24,18 +26,25 @@ class Loader : LinearLayout {
|
|||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0);
|
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0);
|
||||||
_automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false);
|
_automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false);
|
||||||
|
_isWhite = attrArr.getBoolean(R.styleable.LoaderView_isWhite, false);
|
||||||
attrArr.recycle();
|
attrArr.recycle();
|
||||||
} else {
|
} else {
|
||||||
_automatic = false;
|
_automatic = false;
|
||||||
|
_isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
visibility = View.GONE;
|
visibility = View.GONE;
|
||||||
|
|
||||||
|
if (_isWhite) {
|
||||||
|
_imageLoader.setColorFilter(Color.WHITE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
constructor(context: Context, automatic: Boolean, height: Int = -1) : super(context) {
|
constructor(context: Context, automatic: Boolean, height: Int = -1, isWhite: Boolean = false) : super(context) {
|
||||||
inflate(context, R.layout.view_loader, this);
|
inflate(context, R.layout.view_loader, this);
|
||||||
_imageLoader = findViewById(R.id.image_loader);
|
_imageLoader = findViewById(R.id.image_loader);
|
||||||
_animatable = _imageLoader.drawable as Animatable;
|
_animatable = _imageLoader.drawable as Animatable;
|
||||||
_automatic = automatic;
|
_automatic = automatic;
|
||||||
|
_isWhite = isWhite;
|
||||||
|
|
||||||
if(height > 0) {
|
if(height > 0) {
|
||||||
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
|
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
|
||||||
@@ -41,7 +41,7 @@ class MonetizationView : LinearLayout {
|
|||||||
|
|
||||||
private val _textMerchandise: TextView;
|
private val _textMerchandise: TextView;
|
||||||
private val _recyclerMerchandise: RecyclerView;
|
private val _recyclerMerchandise: RecyclerView;
|
||||||
private val _loaderMerchandise: Loader;
|
private val _loaderViewMerchandise: LoaderView;
|
||||||
private val _layoutMerchandise: FrameLayout;
|
private val _layoutMerchandise: FrameLayout;
|
||||||
private var _merchandiseAdapterView: AnyAdapterView<StoreItem, StoreItemViewHolder>? = null;
|
private var _merchandiseAdapterView: AnyAdapterView<StoreItem, StoreItemViewHolder>? = null;
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ class MonetizationView : LinearLayout {
|
|||||||
|
|
||||||
_textMerchandise = findViewById(R.id.text_merchandise);
|
_textMerchandise = findViewById(R.id.text_merchandise);
|
||||||
_recyclerMerchandise = findViewById(R.id.recycler_merchandise);
|
_recyclerMerchandise = findViewById(R.id.recycler_merchandise);
|
||||||
_loaderMerchandise = findViewById(R.id.loader_merchandise);
|
_loaderViewMerchandise = findViewById(R.id.loader_merchandise);
|
||||||
_layoutMerchandise = findViewById(R.id.layout_merchandise);
|
_layoutMerchandise = findViewById(R.id.layout_merchandise);
|
||||||
|
|
||||||
_root = findViewById(R.id.root);
|
_root = findViewById(R.id.root);
|
||||||
@@ -108,7 +108,7 @@ class MonetizationView : LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setMerchandise(items: List<StoreItem>?) {
|
private fun setMerchandise(items: List<StoreItem>?) {
|
||||||
_loaderMerchandise.stop();
|
_loaderViewMerchandise.stop();
|
||||||
|
|
||||||
if (items == null) {
|
if (items == null) {
|
||||||
_textMerchandise.visibility = View.GONE;
|
_textMerchandise.visibility = View.GONE;
|
||||||
@@ -147,7 +147,7 @@ class MonetizationView : LinearLayout {
|
|||||||
val uri = Uri.parse(storeData);
|
val uri = Uri.parse(storeData);
|
||||||
if (uri.isAbsolute) {
|
if (uri.isAbsolute) {
|
||||||
_taskLoadMerchandise.run(storeData);
|
_taskLoadMerchandise.run(storeData);
|
||||||
_loaderMerchandise.start();
|
_loaderViewMerchandise.start();
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
|
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
val onAddToClicked = Event1<IPlatformContent>();
|
val onAddToClicked = Event1<IPlatformContent>();
|
||||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||||
|
val onLongPress = Event1<IPlatformContent>();
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return _cache.size;
|
return _cache.size;
|
||||||
@@ -55,6 +56,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
|
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
|
||||||
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
|
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
|
||||||
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
|
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
|
||||||
|
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
|
||||||
};
|
};
|
||||||
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
|
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
|
||||||
//2 -> ChannelStoreFragment.newInstance();
|
//2 -> ChannelStoreFragment.newInstance();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.views.adapters
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@@ -37,8 +38,10 @@ class CommentViewHolder : ViewHolder {
|
|||||||
private val _layoutRating: LinearLayout;
|
private val _layoutRating: LinearLayout;
|
||||||
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
|
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
|
||||||
private val _layoutComment: ConstraintLayout;
|
private val _layoutComment: ConstraintLayout;
|
||||||
|
private val _buttonDelete: FrameLayout;
|
||||||
|
|
||||||
var onClick = Event1<IPlatformComment>();
|
var onRepliesClick = Event1<IPlatformComment>();
|
||||||
|
var onDelete = Event1<IPlatformComment>();
|
||||||
var comment: IPlatformComment? = null
|
var comment: IPlatformComment? = null
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ class CommentViewHolder : ViewHolder {
|
|||||||
_buttonReplies = itemView.findViewById(R.id.button_replies);
|
_buttonReplies = itemView.findViewById(R.id.button_replies);
|
||||||
_layoutRating = itemView.findViewById(R.id.layout_rating);
|
_layoutRating = itemView.findViewById(R.id.layout_rating);
|
||||||
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
|
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
|
||||||
|
_buttonDelete = itemView.findViewById(R.id.button_delete);
|
||||||
|
|
||||||
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
|
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
|
||||||
val c = comment
|
val c = comment
|
||||||
@@ -75,7 +79,7 @@ class CommentViewHolder : ViewHolder {
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers.", e)
|
Logger.e(TAG, "Failed to backfill servers.", e)
|
||||||
@@ -87,7 +91,12 @@ class CommentViewHolder : ViewHolder {
|
|||||||
|
|
||||||
_buttonReplies.onClick.subscribe {
|
_buttonReplies.onClick.subscribe {
|
||||||
val c = comment ?: return@subscribe;
|
val c = comment ?: return@subscribe;
|
||||||
onClick.emit(c);
|
onRepliesClick.emit(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buttonDelete.setOnClickListener {
|
||||||
|
val c = comment ?: return@setOnClickListener;
|
||||||
|
onDelete.emit(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
|
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
|
||||||
@@ -108,7 +117,8 @@ class CommentViewHolder : ViewHolder {
|
|||||||
|
|
||||||
val rating = comment.rating;
|
val rating = comment.rating;
|
||||||
if (rating is RatingLikeDislikes) {
|
if (rating is RatingLikeDislikes) {
|
||||||
_layoutComment.alpha = if (rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
|
_layoutComment.alpha = if (Settings.instance.comments.badReputationCommentsFading &&
|
||||||
|
rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
|
||||||
} else {
|
} else {
|
||||||
_layoutComment.alpha = 1.0f;
|
_layoutComment.alpha = 1.0f;
|
||||||
}
|
}
|
||||||
@@ -167,6 +177,13 @@ class CommentViewHolder : ViewHolder {
|
|||||||
_buttonReplies.visibility = View.GONE;
|
_buttonReplies.visibility = View.GONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val processHandle = StatePolycentric.instance.processHandle
|
||||||
|
if (processHandle != null && comment is PolycentricPlatformComment && processHandle.system == comment.eventPointer.system) {
|
||||||
|
_buttonDelete.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
_buttonDelete.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
this.comment = comment;
|
this.comment = comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package com.futo.platformplayer.views.adapters
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
|
import com.futo.platformplayer.views.pills.PillButton
|
||||||
|
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||||
|
import com.futo.polycentric.core.Opinion
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.IdentityHashMap
|
||||||
|
|
||||||
|
class CommentWithReferenceViewHolder : ViewHolder {
|
||||||
|
private val _creatorThumbnail: CreatorThumbnail;
|
||||||
|
private val _textAuthor: TextView;
|
||||||
|
private val _textMetadata: TextView;
|
||||||
|
private val _textBody: TextView;
|
||||||
|
private val _buttonReplies: PillButton;
|
||||||
|
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
|
||||||
|
private val _layoutComment: ConstraintLayout;
|
||||||
|
private val _buttonDelete: FrameLayout;
|
||||||
|
private val _cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies>;
|
||||||
|
private var _likesDislikesReplies: StatePolycentric.LikesDislikesReplies? = null;
|
||||||
|
|
||||||
|
private val _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, ::getLikesDislikesReplies)
|
||||||
|
.success {
|
||||||
|
_likesDislikesReplies = it
|
||||||
|
updateLikesDislikesReplies()
|
||||||
|
}
|
||||||
|
.exception<Throwable> {
|
||||||
|
Logger.w(TAG, "Failed to get live comment.", it);
|
||||||
|
//TODO: Show error
|
||||||
|
hideLikesDislikesReplies()
|
||||||
|
}
|
||||||
|
|
||||||
|
var onRepliesClick = Event1<IPlatformComment>();
|
||||||
|
var onDelete = Event1<IPlatformComment>();
|
||||||
|
var comment: IPlatformComment? = null
|
||||||
|
private set;
|
||||||
|
|
||||||
|
constructor(viewGroup: ViewGroup, cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies>) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment_with_reference, viewGroup, false)) {
|
||||||
|
_layoutComment = itemView.findViewById(R.id.layout_comment);
|
||||||
|
_creatorThumbnail = itemView.findViewById(R.id.image_thumbnail);
|
||||||
|
_textAuthor = itemView.findViewById(R.id.text_author);
|
||||||
|
_textMetadata = itemView.findViewById(R.id.text_metadata);
|
||||||
|
_textBody = itemView.findViewById(R.id.text_body);
|
||||||
|
_buttonReplies = itemView.findViewById(R.id.button_replies);
|
||||||
|
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
|
||||||
|
_buttonDelete = itemView.findViewById(R.id.button_delete)
|
||||||
|
_cache = cache
|
||||||
|
|
||||||
|
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
|
||||||
|
val c = comment
|
||||||
|
if (c !is PolycentricPlatformComment) {
|
||||||
|
throw Exception("Not implemented for non polycentric comments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.hasLiked) {
|
||||||
|
args.processHandle.opinion(c.reference, Opinion.like);
|
||||||
|
} else if (args.hasDisliked) {
|
||||||
|
args.processHandle.opinion(c.reference, Opinion.dislike);
|
||||||
|
} else {
|
||||||
|
args.processHandle.opinion(c.reference, Opinion.neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Started backfill");
|
||||||
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
|
Logger.i(TAG, "Finished backfill");
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to backfill servers.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
|
||||||
|
};
|
||||||
|
|
||||||
|
_buttonReplies.onClick.subscribe {
|
||||||
|
val c = comment ?: return@subscribe;
|
||||||
|
onRepliesClick.emit(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buttonDelete.setOnClickListener {
|
||||||
|
val c = comment ?: return@setOnClickListener;
|
||||||
|
onDelete.emit(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLikesDislikesReplies(c: PolycentricPlatformComment): StatePolycentric.LikesDislikesReplies {
|
||||||
|
val likesDislikesReplies = StatePolycentric.instance.getLikesDislikesReplies(c.reference)
|
||||||
|
synchronized(_cache) {
|
||||||
|
_cache[c] = likesDislikesReplies
|
||||||
|
}
|
||||||
|
return likesDislikesReplies
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(comment: IPlatformComment) {
|
||||||
|
Log.i(TAG, "bind")
|
||||||
|
|
||||||
|
_likesDislikesReplies = null;
|
||||||
|
_taskGetLiveComment.cancel()
|
||||||
|
|
||||||
|
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
|
||||||
|
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
|
||||||
|
_textAuthor.text = comment.author.name;
|
||||||
|
|
||||||
|
val date = comment.date;
|
||||||
|
if (date != null) {
|
||||||
|
_textMetadata.visibility = View.VISIBLE;
|
||||||
|
_textMetadata.text = " • ${date.toHumanNowDiffString()} ago";
|
||||||
|
} else {
|
||||||
|
_textMetadata.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
val rating = comment.rating;
|
||||||
|
if (rating is RatingLikeDislikes) {
|
||||||
|
_layoutComment.alpha = if (Settings.instance.comments.badReputationCommentsFading &&
|
||||||
|
rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
|
||||||
|
} else {
|
||||||
|
_layoutComment.alpha = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
_textBody.text = comment.message.fixHtmlLinks();
|
||||||
|
|
||||||
|
this.comment = comment;
|
||||||
|
updateLikesDislikesReplies();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLikesDislikesReplies() {
|
||||||
|
Log.i(TAG, "updateLikesDislikesReplies")
|
||||||
|
|
||||||
|
val c = comment ?: return
|
||||||
|
if (c is PolycentricPlatformComment) {
|
||||||
|
if (_likesDislikesReplies == null) {
|
||||||
|
Log.i(TAG, "updateLikesDislikesReplies retrieving from cache")
|
||||||
|
|
||||||
|
synchronized(_cache) {
|
||||||
|
_likesDislikesReplies = _cache[c]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val likesDislikesReplies = _likesDislikesReplies
|
||||||
|
if (likesDislikesReplies != null) {
|
||||||
|
Log.i(TAG, "updateLikesDislikesReplies set")
|
||||||
|
|
||||||
|
val hasLiked = StatePolycentric.instance.hasLiked(c.reference);
|
||||||
|
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference);
|
||||||
|
_pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
|
||||||
|
|
||||||
|
_buttonReplies.setLoading(false)
|
||||||
|
|
||||||
|
val replies = likesDislikesReplies.replyCount ?: 0;
|
||||||
|
_buttonReplies.visibility = View.VISIBLE;
|
||||||
|
_buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies);
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "updateLikesDislikesReplies to load")
|
||||||
|
|
||||||
|
_pillRatingLikesDislikes.setLoading(true)
|
||||||
|
_buttonReplies.setLoading(true)
|
||||||
|
_taskGetLiveComment.run(c)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hideLikesDislikesReplies()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideLikesDislikesReplies() {
|
||||||
|
_pillRatingLikesDislikes.visibility = View.GONE
|
||||||
|
_buttonReplies.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CommentWithReferenceViewHolder";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,9 +74,9 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
} else if (d is AirPlayCastingDevice) {
|
} else if (d is AirPlayCastingDevice) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
} else if (d is FastCastCastingDevice) {
|
} else if (d is FCastCastingDevice) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||||
_textType.text = "FastCast";
|
_textType.text = "FCast";
|
||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user