Compare commits

...

76 Commits

Author SHA1 Message Date
Kelvin a1060a15be Merge 2023-12-05 21:04:59 +01:00
Kelvin dc7b2f420b Refs 2023-12-05 21:03:58 +01:00
Kelvin b35390a4bb Merge branch 'db-store' into 'master'
WIP DBStore

See merge request videostreaming/grayjay!8
2023-12-05 19:44:19 +00:00
Kelvin 3b253ad2b6 Merge 2023-12-05 20:41:06 +01:00
Kelvin 06c39ce973 QueryIn support, channel cache query grouped 2023-12-05 17:04:09 +01:00
Koen 11b8914615 Fixed polycentric disable fallback. 2023-12-05 16:10:51 +01:00
Koen e45c8617df Cleanup. 2023-12-05 15:21:35 +01:00
Koen 9075a2599c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-05 15:20:05 +01:00
Koen dd8d50e0e2 Added disable polycentric setting. 2023-12-05 15:19:54 +01:00
Kelvin 55a11d82ac Creator search, toggle to disable bad rep comments fading 2023-12-05 14:15:39 +01:00
Kelvin 7ee4f411cb Import dialog 2023-12-04 22:18:07 +01:00
Kelvin c9d5508018 Refs 2023-12-04 20:08:29 +01:00
Kelvin bef8fc682c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-04 20:07:32 +01:00
Kelvin c37d464403 Refs 2023-12-04 20:07:14 +01:00
Kelvin cbf2712654 ManagedDBSTore delete corrupted items, Fix serialized content serializer, Fix notifications wrong intent 2023-12-04 20:06:24 +01:00
Koen 08134b4427 If cache entry is expired, load it from cache, then reload it from live. 2023-12-04 17:09:27 +01:00
Koen f90290c4ec Added support for connecting to FCast via QR code. 2023-12-04 10:57:11 +01:00
Kelvin 7cde8ed538 Filler dev options 2023-12-01 16:45:17 +01:00
Kelvin 585cf090d6 DBStore improvements, query like support, more unittest, refactor into StateHistory, history indexes 2023-12-01 16:09:41 +01:00
Koen 23d1085755 Fixes to connectivity loss playback restart and fixes to added ensureEnoughContentVisible. 2023-12-01 15:01:41 +01:00
Koen fc5888d57e Added setting to allow restarting playback after connectivity loss behavior to be changed. 2023-12-01 14:11:15 +01:00
Kelvin c5541b1747 Working DBCache, test plugin 2023-11-30 20:58:37 +01:00
Koen 0fd8ba28bb Chromecast protobuf cleanup and fixed Odysee content-types being misrepresented causing casting to desktop to break. 2023-11-30 14:21:42 +01:00
Koen 6d9f4959e0 Removed Logger. 2023-11-30 11:50:52 +01:00
Koen 4be4bb631f Fixed gesture control issues causing wrong area to have gesture controls and disabled full screen gesture when casting. 2023-11-30 11:40:58 +01:00
Koen 948f5a2a6d Added FCast guide and other casting help options. 2023-11-30 09:56:40 +01:00
Koen baad342aec Fixed Rumble comments and show error in CommentList whenever an error happens. 2023-11-30 08:47:07 +01:00
Kelvin aeb29c54cd WIP Channel content cache 2023-11-30 00:12:46 +01:00
Koen a5dfa653ad Removed last mentions of FastCast and added backwards compatibility. 2023-11-29 16:02:58 +01:00
Koen 3387c727d1 Proper implementation for replies/likes/dislikes in the comment tab. 2023-11-29 15:48:34 +01:00
Koen c806ff2e33 Added support for comment deletion. 2023-11-29 13:54:26 +01:00
Koen 1db4d427fc Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-29 13:43:49 +01:00
Koen 3bf73ed5e8 Implemented delete comment support. Implemented Comments tab. Implemented replies overlay showing parent comment. 2023-11-29 13:43:40 +01:00
Gabe Rogan db44aa2c4d Merge branch 'quickstart-docs' into 'master'
Add quickstart docs

See merge request videostreaming/grayjay!7
2023-11-28 14:32:40 +00:00
Gabe Rogan 0e6e381800 Custom HTTP server wording 2023-11-28 09:31:46 -05:00
Gabe Rogan 69e43dc533 Split docs into multiple pages 2023-11-28 09:11:42 -05:00
Gabe Rogan ee4442d553 Add quickstart docs 2023-11-27 16:20:53 -05:00
Kelvin c49b9f7841 DBStore query support and tests 2023-11-27 17:38:55 +01:00
Koen 8a35cd0e82 Added settings to allow different behavior when audio focus is regained within 10 seconds. 2023-11-27 17:08:40 +01:00
Koen 0ae90ecf03 Updated Playstore flow for URL handling. 2023-11-27 16:27:56 +01:00
Koen 3d2840fe15 Merge branch 'hls-download' into 'master'
HLS download implementation

See merge request videostreaming/grayjay!6
2023-11-27 13:49:34 +00:00
Koen b6ad3fd991 HLS download implementation 2023-11-27 13:49:34 +00:00
Koen 2ee3c30b0e Better URL handling support. Prompt user to set Grayjay as a default handler for certain URLs. 2023-11-27 12:10:53 +01:00
Kelvin 662e94bcee Unittests and fixes for dbstore 2023-11-24 22:42:30 +01:00
Kelvin f3c9e0196e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store 2023-11-24 15:22:34 +01:00
Kelvin f15eb9bf9e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-24 15:22:08 +01:00
Kelvin 12b2552185 Settings search, Fix nested video events, Adding setting descriptions for metered 2023-11-24 15:22:03 +01:00
Koen d245e20b14 Chromecast socket crash fix. 2023-11-24 11:24:52 +01:00
Koen e47349d010 Added OPTIONS headers where necessary and further HLS spec implementations. 2023-11-24 10:37:18 +01:00
Kelvin eb3dd854d4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 17:28:23 +01:00
Kelvin c529446219 Attempt to fetch live videos for offline videos 2023-11-23 17:28:14 +01:00
Koen fa2f8c3447 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 16:45:09 +01:00
Koen 840d1ae534 Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. 2023-11-23 16:44:58 +01:00
Kelvin 2530c6eb58 Live chat improvements and fixes 2023-11-23 16:35:13 +01:00
Kelvin 869789f0e2 WIP 2023-11-23 16:03:25 +01:00
Koen ee3761c780 Added full support for HLS casting to Airplay. 2023-11-23 13:18:09 +01:00
Koen e4c89e9aa9 Extended HLS spec, fixes to YES NO booleans, started on implementing HLS stream combiner. 2023-11-23 12:48:16 +01:00
Koen 9d5888ddf7 Fixed VODs not working properly for YouTube and Twitch. 2023-11-23 11:48:50 +01:00
Koen ecc94920d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 22:33:05 +01:00
Koen 5cafbf243e Fixed channel contents long press and fixed a crash due to time bars. 2023-11-22 22:32:44 +01:00
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin b65fc594dc Working history DB implementation 2023-11-20 21:27:27 +01:00
Kelvin f52b731615 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store 2023-11-20 14:24:48 +01:00
Kelvin 99c06c516f WIP Store/testing 2023-11-17 22:17:49 +01:00
Kelvin 10e3d2122f wip 2023-11-16 20:32:15 +01:00
190 changed files with 6847 additions and 1637 deletions
+8 -1
View File
@@ -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);
}
}
+12
View File
@@ -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;
}
} }
} }
@@ -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
@@ -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);
} }
} }
@@ -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())
@@ -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 {
@@ -0,0 +1,51 @@
package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
class HLSVariantVideoUrlSource(
override val name: String,
override val width: Int,
override val height: Int,
override val container: String,
override val codec: String,
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
}
}
class HLSVariantAudioUrlSource(
override val name: String,
override val bitrate: Int,
override val container: String,
override val codec: String,
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
}
}
class HLSVariantSubtitleUrlSource(
override val name: String,
override val url: String,
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override fun getSubtitles(): String? {
return null
}
override suspend fun getSubtitlesURI(): Uri? {
return Uri.parse(url)
}
}
@@ -6,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>;
@@ -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);
@@ -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;
@@ -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);
@@ -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;
@@ -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") {
@@ -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();
@@ -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();
} }
} }
@@ -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);
@@ -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;
@@ -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();
} }
} }
@@ -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
}
}
}
}
@@ -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);
} }
} }
@@ -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 ->
@@ -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;
}
} }
} }
@@ -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 {
@@ -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)
@@ -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);
@@ -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();
}
}
}
@@ -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());
} }
} }
@@ -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);
} }
} }
} }
@@ -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();
}
}
@@ -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;
} }
@@ -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