Compare commits

...

62 Commits

Author SHA1 Message Date
Kelvin 0ae05e7cd4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-06 19:46:14 +01:00
Kelvin b284176072 Loop support, Improve add to queue behavior, home retry, fix history search pager, defaults progressbar 2023-12-06 19:46:09 +01:00
Koen 5fffaf2f4e Added next/previous skip buttons. 2023-12-06 19:40:05 +01:00
Koen 58da91eae8 Made history properly reload. 2023-12-06 16:47:22 +01:00
Koen 98d92d3fe2 Updated HistoryView to use pager. 2023-12-06 16:32:17 +01:00
Koen c5d35b27f0 Cleanup on store PR. 2023-12-06 13:41:07 +01:00
Koen aee5b75c2f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-06 10:34:32 +01:00
Koen fe02197bd8 Fixes to Polycentric flows. 2023-12-06 10:34:20 +01:00
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 869789f0e2 WIP 2023-11-23 16:03:25 +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
174 changed files with 6681 additions and 1747 deletions
+8 -1
View File
@@ -5,6 +5,7 @@ plugins {
id 'org.ajoberstar.grgit' version '1.7.2'
id 'com.google.protobuf'
id 'kotlin-parcelize'
id 'kotlin-kapt'
}
ext {
@@ -38,7 +39,7 @@ protobuf {
android {
namespace 'com.futo.platformplayer'
compileSdk 33
compileSdk 34
flavorDimensions "buildType"
productFlavors {
stable {
@@ -194,6 +195,12 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
//Database
implementation("androidx.room:room-runtime:2.6.0")
annotationProcessor("androidx.room:room-compiler:2.6.0")
kapt("androidx.room:room-compiler:2.6.0")
implementation("androidx.room:room-ktx:2.6.0")
//Payment
implementation 'com.stripe:stripe-android:20.28.3'
@@ -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" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fcast" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -210,5 +218,9 @@
android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.FCastGuideActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>
@@ -8,7 +8,6 @@ import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger
@@ -23,6 +22,7 @@ import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -30,7 +30,6 @@ import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.io.File
import java.time.OffsetDateTime
import java.util.Locale
@Serializable
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
@@ -45,19 +44,23 @@ class Settings : FragmentedStorageFileJson() {
@Transient
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)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
} else {
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
}
} else {
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
UIDialogs.toast(it, "Polycentric is disabled")
}
}
}
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
@@ -67,7 +70,7 @@ class Settings : FragmentedStorageFileJson() {
//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)
fun openIssues() {
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)
fun manageTabs() {
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)
var language = LanguageSettings();
@Serializable
@@ -159,7 +181,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
var progressBar: Boolean = true;
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
@@ -190,7 +212,7 @@ class Settings : FragmentedStorageFileJson() {
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
var progressBar: Boolean = true;
fun getSearchFeedStyle(): FeedStyle {
@@ -208,7 +230,7 @@ class Settings : FragmentedStorageFileJson() {
class ChannelSettings {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
var progressBar: Boolean = true;
}
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
@@ -230,7 +252,7 @@ class Settings : FragmentedStorageFileJson() {
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
var progressBar: Boolean = true;
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
@Serializable(with = FlexibleBooleanSerializer::class)
@@ -276,7 +298,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
ChannelContentCache.instance.clear();
StateCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
}
@@ -307,29 +329,29 @@ class Settings : FragmentedStorageFileJson() {
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)
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)
var preferredMeteredQuality: Int = 0;
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
@FormField(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)
var preferredPreviewQuality: Int = 5;
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)
var autoRotate: Int = 2;
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)
var autoRotateDeadZone: Int = 0;
@@ -337,7 +359,7 @@ class Settings : FragmentedStorageFileJson() {
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)
var backgroundPlay: Int = 2;
@@ -377,6 +399,14 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterLoss: Int = 1;
@FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterConnectivityLoss: Int = 1;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -386,6 +416,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.comment_sections)
var defaultCommentSection: Int = 0;
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
var badReputationCommentsFading: Boolean = true;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
@@ -693,25 +726,16 @@ 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)
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, 17)
@@ -738,6 +762,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
var polycentricEnabled: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
@@ -2,6 +2,7 @@ package com.futo.platformplayer
import android.content.Context
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
@@ -12,25 +13,31 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.DeveloperActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.views.fields.ButtonField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import kotlinx.coroutines.CoroutineScope
@@ -39,6 +46,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream.range
@@ -82,26 +90,153 @@ class SettingsDev : FragmentedStorageFileJson() {
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,
R.string.crashes_the_application_on_purpose, 2)
R.string.crashes_the_application_on_purpose, 3)
fun crashMe() {
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
}
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
R.string.delete_all_announcements, 2)
R.string.delete_all_announcements, 3)
fun deleteAnnouncements() {
StateAnnouncement.instance.deleteAllAnnouncements();
}
@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() {
val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null);
}
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
R.string.test_background_worker_description, 4)
fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!;
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
@@ -113,10 +248,10 @@ class SettingsDev : FragmentedStorageFileJson() {
wm.enqueue(req);
}
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
R.string.test_background_worker_description, 4)
fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
ChannelContentCache.instance.clearToday();
StateCache.instance.clearToday();
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
override fun encode(): String {
return Json.encodeToString(this);
@@ -1,8 +1,11 @@
package com.futo.platformplayer
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
@@ -10,12 +13,12 @@ import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.*
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
@@ -91,6 +94,50 @@ class UIDialogs {
}.toTypedArray());
}
fun showUrlHandlingPrompt(context: Context, onYes: (() -> Unit)? = null) {
val builder = AlertDialog.Builder(context)
val view = LayoutInflater.from(context).inflate(R.layout.dialog_url_handling, null)
builder.setView(view)
val dialog = builder.create()
registerDialogOpened(dialog)
view.findViewById<TextView>(R.id.button_no).apply {
this.setOnClickListener {
dialog.dismiss()
}
}
view.findViewById<LinearLayout>(R.id.button_yes).apply {
this.setOnClickListener {
if (BuildConfig.IS_PLAYSTORE_BUILD) {
dialog.dismiss()
showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.play_store_version_does_not_support_default_url_handling)) {
onYes?.invoke()
}
} else {
try {
val intent =
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", context.packageName, null)
intent.data = uri
context.startActivity(intent)
} catch (e: Throwable) {
toast(context, context.getString(R.string.failed_to_show_settings))
}
onYes?.invoke()
dialog.dismiss()
}
}
}
dialog.setOnDismissListener {
registerDialogClosed(dialog)
}
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
@@ -107,7 +154,8 @@ class UIDialogs {
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY)
);
else {
dialogAction();
}
@@ -142,8 +190,10 @@ class UIDialogs {
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if(code == null)
this.visibility = View.GONE;
else
else {
this.text = code;
this.visibility = View.VISIBLE;
}
};
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
val buttons = actions.map<Action, TextView> { act ->
@@ -279,6 +329,12 @@ class UIDialogs {
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showImportOptionsDialog(context: MainActivity) {
val dialog = ImportOptionsDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingDialog(context: Context) {
@@ -291,11 +347,22 @@ class UIDialogs {
} else {
val dialog = ConnectCastingDialog(context);
registerDialogOpened(dialog);
val c = context
if (c is Activity) {
dialog.setOwnerActivity(c);
}
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
}
fun showCastingTutorialDialog(context: Context) {
val dialog = CastingHelpDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingAddDialog(context: Context) {
val dialog = CastingAddDialog(context);
registerDialogOpened(dialog);
@@ -1,20 +1,17 @@
package com.futo.platformplayer
import android.content.ContentResolver
import android.graphics.Color
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.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.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
@@ -24,8 +21,9 @@ import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@@ -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? {
val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
@@ -166,30 +259,49 @@ class UISlideOverlays {
videoSources
.filter { it.isDownloadable() }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
if (it is IVideoUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
} else if (it is IHLSManifestSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
}).flatten().toList()
));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
//TODO: Add HLS support here
selectedVideo = VideoHelper.selectBestVideoSource(
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
}
audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
if (it is IAudioUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
} else if (it is IHLSManifestAudioSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
}));
val asources = audioSources;
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
@@ -198,15 +310,15 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
//TODO: Add HLS support here
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
}
//ContentResolver is required for subtitles..
if(contentResolver != null) {
if(contentResolver != null && subtitleSources.isNotEmpty()) {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
.map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
@@ -378,7 +490,7 @@ class UISlideOverlays {
val dp70 = 70.dp(container.context.resources);
val dp15 = 15.dp(container.context.resources);
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
Loader(container.context, true, dp70).apply {
LoaderView(container.context, true, dp70).apply {
this.setPadding(0, dp15, 0, dp15);
}
), true);
@@ -1,17 +1,24 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperActivity : AppCompatActivity() {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
fun getField(id: String): IField? {
return _form.findField(id);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
DeveloperActivity._lastActivity = this;
setContentView(R.layout.activity_dev);
setNavigationBarColorAndIcons();
@@ -33,4 +40,19 @@ class DeveloperActivity : AppCompatActivity() {
super.finish()
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: DeveloperActivity? = null;
fun getActivity(): DeveloperActivity? {
val act = _lastActivity;
if(act != null)
return act;
return null;
}
}
}
@@ -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,6 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.util.TypedValue
import android.view.View
@@ -25,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.dialogs.ConnectCastingDialog
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -45,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@@ -90,6 +88,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
lateinit var _fragMainSuggestions: SuggestionsFragment;
lateinit var _fragMainSubscriptions: CreatorsFragment;
lateinit var _fragMainComments: CommentsFragment;
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment;
@@ -123,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true;
private var _wasStopped = false;
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
try {
handleUrlAll(content)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle URL.", e)
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
}
}
}
constructor() : super() {
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
val writer = StringWriter();
@@ -205,6 +222,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
_fragMainSubscriptions = CreatorsFragment.newInstance();
_fragMainComments = CommentsFragment.newInstance();
_fragMainChannel = ChannelFragment.newInstance();
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
_fragMainSources = SourcesFragment.newInstance();
@@ -282,6 +300,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Set top bars
_fragMainHome.topBar = _fragTopBarGeneral;
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
_fragMainComments.topBar = _fragTopBarGeneral;
_fragMainSuggestions.topBar = _fragTopBarSearch;
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
@@ -406,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/
fun showUrlQrCodeScanner() {
try {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.captureActivity = QRCaptureActivity::class.java
_urlQrCodeResultLauncher.launch(integrator.createScanIntent())
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle show QR scanner.", e)
UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}")
}
}
override fun onResume() {
super.onResume();
Logger.v(TAG, "onResume")
@@ -479,6 +515,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"IMPORT_OPTIONS" -> {
UIDialogs.showImportOptionsDialog(this);
}
"TAB" -> {
when(intent.getStringExtra("TAB")){
"Sources" -> {
@@ -493,76 +532,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try {
if (targetData != null) {
when(intent.scheme) {
"grayjay" -> {
if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, 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",
{ });
}
}
}
handleUrlAll(targetData)
}
}
catch(ex: Throwable) {
@@ -570,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 {
Logger.i(TAG, "handleUrl(url=$url)")
@@ -679,18 +733,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
val jsonSubs = newPipeSubsParsed["subscriptions"]
val jsonSubsArray = jsonSubs.asJsonArray;
val jsonSubsArrayItt = jsonSubsArray.iterator();
val subs = mutableListOf<String>()
while(jsonSubsArrayItt.hasNext()) {
val jsonSubObj = jsonSubsArrayItt.next().asJsonObject;
if(jsonSubObj.has("url"))
subs.add(jsonSubObj["url"].asString);
}
navigate(_fragImportSubscriptions, subs);
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
}
catch(ex: Exception) {
Logger.e(TAG, ex.message, ex);
@@ -716,6 +759,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
return true;
}
fun handleFCast(url: String): Boolean {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(this, url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
}
return false
}
private fun readSharedContent(contentPath: String): ByteArray {
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
return it.readBytes();
@@ -916,6 +973,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T;
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
ChannelFragment::class -> _fragMainChannel as T;
@@ -988,5 +1046,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getImportOptionsIntent(context: Context): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "IMPORT_OPTIONS";
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
@@ -15,7 +16,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
@@ -23,13 +24,15 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loader: Loader;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@@ -43,7 +46,8 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loader = findViewById(R.id.loader);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, value ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
@@ -69,9 +73,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
}
fun reloadSettings() {
_loader.start();
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
@@ -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.handlers.HttpFuntionHandler
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.OutputStream
import java.lang.reflect.Field
@@ -141,6 +142,23 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
}
return handler;
}
fun addHandlerWithAllowAllOptions(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
val allowedMethods = arrayListOf(handler.method, "OPTIONS")
if (withHEAD) {
allowedMethods.add("HEAD")
}
val tag = handler.tag
if (tag != null) {
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods).withTag(tag))
} else {
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods))
}
return addHandler(handler, withHEAD)
}
fun removeHandler(method: String, path: String) {
synchronized(_handlers) {
val handlerMap = _handlers[method] ?: return
@@ -2,11 +2,17 @@ package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
class HttpOptionsAllowHandler(path: String, val allowedMethods: List<String> = listOf()) : HttpHandler("OPTIONS", path) {
override fun handle(httpContext: HttpContext) {
val newHeaders = headers.clone()
newHeaders.put("Access-Control-Allow-Origin", "*")
newHeaders.put("Access-Control-Allow-Methods", "*")
if (allowedMethods.isNotEmpty()) {
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
} else {
newHeaders.put("Access-Control-Allow-Methods", "*")
}
newHeaders.put("Access-Control-Allow-Headers", "*")
httpContext.respondCode(200, newHeaders);
}
@@ -4,10 +4,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.Pointer
import com.futo.polycentric.core.SignedEvent
import userpackage.Protocol.Reference
import java.time.OffsetDateTime
@@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
override val replyCount: Int?;
val eventPointer: Pointer;
val reference: Reference;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) {
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
this.reference = reference;
this.eventPointer = eventPointer;
this.reference = eventPointer.toReference();
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
@@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
}
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
}
companion object {
@@ -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.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class)
interface SerializedPlatformContent: IPlatformContent {
override val contentType: ContentType;
fun toJson() : String;
fun fromJson(str : String) : SerializedPlatformContent;
fun fromJsonArray(str : String) : Array<SerializedPlatformContent>;
@@ -30,7 +30,7 @@ open class SerializedPlatformLockedContent(
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.LOCKED;
override val contentType: ContentType = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?,
override val contentThumbnails: Thumbnails
) : IPlatformNestedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
final override val contentType: ContentType = ContentType.NESTED_VIDEO;
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
override val contentSupported: Boolean get() = contentPlugin != null;
@@ -8,6 +8,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -26,7 +27,7 @@ open class SerializedPlatformPost(
override val thumbnails: List<Thumbnails?>,
override val images: List<String>
) : IPlatformPost, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.POST;
override val contentType: ContentType = ContentType.POST;
override fun toJson() : String {
return Json.encodeToString(this);
@@ -26,7 +26,7 @@ open class SerializedPlatformVideo(
override val duration: Long,
override val viewCount: Long,
) : IPlatformVideo, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.MEDIA;
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false;
@@ -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;
}
}
@@ -122,7 +122,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
//Only for testing notifications
val testNotifs = 0;
if(contentNotifs.size == 0 && testNotifs > 0) {
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
results.first.getResults().filter { it is IPlatformVideo }
.take(testNotifs).forEach {
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 com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
@@ -49,7 +48,7 @@ class AirPlayCastingDevice : CastingDevice {
return;
}
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
if (resumePosition > 0.0) {
@@ -1,10 +1,15 @@
package com.futo.platformplayer.casting
import android.content.Context
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.net.InetAddress
import java.time.OffsetDateTime
@@ -14,10 +19,27 @@ enum class CastConnectionState {
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FASTCAST
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDevice {
@@ -2,18 +2,16 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.DeviceAuthMessageOuterClass
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.*
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.net.InetAddress
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
@@ -314,6 +312,7 @@ class ChromecastCastingDevice : CastingDevice {
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
@@ -324,7 +323,7 @@ class ChromecastCastingDevice : CastingDevice {
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: IOException) {
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
@@ -375,7 +374,7 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = DeviceAuthMessageOuterClass.CastMessage.parseFrom(messageBytes);
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
@@ -428,12 +427,12 @@ class ChromecastCastingDevice : CastingDevice {
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder()
.setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0)
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
@@ -447,8 +446,8 @@ class ChromecastCastingDevice : CastingDevice {
}
}
private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) {
if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) {
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
@@ -30,10 +30,10 @@ enum class Opcode(val value: Byte) {
SET_VOLUME(8)
}
class FastCastCastingDevice : CastingDevice {
class FCastCastingDevice : CastingDevice {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FASTCAST;
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
@@ -72,7 +72,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
sendMessage(Opcode.PLAY, FastCastPlayMessage(
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition.toInt()
@@ -87,7 +87,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
sendMessage(Opcode.PLAY, FastCastPlayMessage(
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition.toInt()
@@ -100,7 +100,7 @@ class FastCastCastingDevice : CastingDevice {
}
this.volume = volume
sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume))
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
}
override fun seekVideo(timeSeconds: Double) {
@@ -108,7 +108,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.SEEK, FastCastSeekMessage(
sendMessage(Opcode.SEEK, FCastSeekMessage(
time = timeSeconds.toInt()
));
}
@@ -282,7 +282,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
val playbackUpdate = Json.decodeFromString<FastCastPlaybackUpdateMessage>(json);
val playbackUpdate = Json.decodeFromString<FCastPlaybackUpdateMessage>(json);
time = playbackUpdate.time.toDouble();
isPlaying = when (playbackUpdate.state) {
1 -> true
@@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
val volumeUpdate = Json.decodeFromString<FastCastVolumeUpdateMessage>(json);
val volumeUpdate = Json.decodeFromString<FCastVolumeUpdateMessage>(json);
volume = volumeUpdate.volume;
}
else -> { }
@@ -398,7 +398,7 @@ class FastCastCastingDevice : CastingDevice {
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FASTCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
@@ -2,8 +2,11 @@ package com.futo.platformplayer.casting
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Base64
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.*
@@ -27,6 +30,9 @@ import javax.jmdns.ServiceListener
import kotlin.collections.HashMap
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import javax.jmdns.ServiceTypeListener
class StateCasting {
@@ -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() {
val ad = activeDevice ?: return;
Logger.i(TAG, "Stopping active device because of onStop.");
@@ -345,7 +377,7 @@ class StateCasting {
} else {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
if (ad is FastCastCastingDevice) {
if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else if (ad is AirPlayCastingDevice) {
@@ -370,7 +402,7 @@ class StateCasting {
} else if(videoSource is IHLSManifestSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, resumePosition);
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
@@ -378,7 +410,7 @@ class StateCasting {
} else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, resumePosition);
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
@@ -442,7 +474,7 @@ class StateCasting {
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -461,7 +493,7 @@ class StateCasting {
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -492,7 +524,7 @@ class StateCasting {
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
if (videoSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
@@ -501,9 +533,9 @@ class StateCasting {
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, videoVariantPlaylistSegments)
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -514,7 +546,7 @@ class StateCasting {
}
if (audioSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
@@ -523,9 +555,9 @@ class StateCasting {
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, audioVariantPlaylistSegments)
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -535,7 +567,7 @@ class StateCasting {
}
if (subtitleSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
@@ -544,9 +576,9 @@ class StateCasting {
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, subtitleVariantPlaylistSegments)
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -556,7 +588,7 @@ class StateCasting {
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -584,43 +616,28 @@ class StateCasting {
val audioUrl = url + audioPath;
val subtitleUrl = url + subtitlePath;
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
"application/dash+xml")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
if (videoSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(videoPath)
.withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alive"))
.withTag("cast");
}
if (audioSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(audioPath)
.withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alive"))
.withTag("cast");
}
if (subtitleSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(subtitlePath)
.withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alive"))
.withTag("cast");
}
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
@@ -654,7 +671,7 @@ class StateCasting {
}
if (content != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -674,7 +691,7 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
}
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf();
@@ -685,13 +702,41 @@ class StateCasting {
val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.removeAllHandlers("castProxiedHlsVariant")
val headers = masterContext.headers.clone()
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 newMediaRenditions = arrayListOf<HLS.MediaRendition>()
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
@@ -701,11 +746,17 @@ class StateCasting {
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
val response = _client.get(variantPlaylistRef.url)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
@@ -725,11 +776,17 @@ class StateCasting {
val newPlaylistPath = "/hls-playlist-${playlistId}"
newPlaylistUrl = url + newPlaylistPath
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
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);
@@ -754,7 +811,7 @@ class StateCasting {
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
//ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition;
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);
@@ -765,7 +822,7 @@ class StateCasting {
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
}
} else {
@@ -779,6 +836,7 @@ class StateCasting {
variantPlaylist.discontinuitySequence,
variantPlaylist.programDateTime,
variantPlaylist.playlistType,
variantPlaylist.streamInfo,
newSegments
)
}
@@ -789,7 +847,7 @@ class StateCasting {
val newSegmentUrl = url + newSegmentPath;
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -826,23 +884,21 @@ class StateCasting {
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, audioVariantPlaylistSegments)
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
@@ -861,11 +917,10 @@ class StateCasting {
}
if (content != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
subtitlesUrl = url + subtitlePath;
@@ -879,14 +934,13 @@ class StateCasting {
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, subtitleVariantPlaylistSegments)
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitleVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
}
@@ -899,14 +953,13 @@ class StateCasting {
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, videoVariantPlaylistSegments)
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
videoSource.bitrate ?: 0,
@@ -918,21 +971,19 @@ class StateCasting {
if (subtitleSource != null) "subtitles" else null,
null, null)))
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectMaster")
_castServer.addHandler(HttpOptionsAllowHandler(hlsPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
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());
@@ -942,7 +993,7 @@ class StateCasting {
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice;
val proxyStreams = ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
@@ -976,11 +1027,10 @@ class StateCasting {
}
if (content != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("cast");
}
subtitlesUrl = url + subtitlePath;
@@ -989,38 +1039,25 @@ class StateCasting {
}
}
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
"application/dash+xml")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(dashPath)
.withHeader("Access-Control-Allow-Origin", "*")
).withTag("cast");
if (videoSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(videoPath)
.withHeader("Access-Control-Allow-Origin", "*")
).withTag("cast");
}
if (audioSource != null) {
_castServer.addHandler(
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(audioPath)
.withHeader("Access-Control-Allow-Origin", "*")
)
.withTag("cast");
}
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
@@ -1037,8 +1074,8 @@ class StateCasting {
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FASTCAST -> {
FastCastCastingDevice(deviceInfo);
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
}
@@ -1085,8 +1122,8 @@ class StateCasting {
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<FastCastCastingDevice>(name,
deviceFactory = { FastCastCastingDevice(name, addresses, port) },
return addOrUpdateCastDevice<FCastCastingDevice>(name,
deviceFactory = { FCastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
@@ -1162,6 +1199,19 @@ class StateCasting {
}
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
val instance: StateCasting = StateCasting();
@@ -3,7 +3,7 @@ package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@kotlinx.serialization.Serializable
data class FastCastPlayMessage(
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
@@ -11,23 +11,23 @@ data class FastCastPlayMessage(
) { }
@kotlinx.serialization.Serializable
data class FastCastSeekMessage(
data class FCastSeekMessage(
val time: Int
) { }
@kotlinx.serialization.Serializable
data class FastCastPlaybackUpdateMessage(
data class FCastPlaybackUpdateMessage(
val time: Int,
val state: Int
) { }
@Serializable
data class FastCastVolumeUpdateMessage(
data class FCastVolumeUpdateMessage(
val volume: Double
)
@Serializable
data class FastCastSetVolumeMessage(
data class FCastSetVolumeMessage(
val volume: Double
)
@@ -12,10 +12,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@@ -26,6 +23,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
private lateinit var _textError: TextView;
private lateinit var _buttonCancel: Button;
private lateinit var _buttonConfirm: LinearLayout;
private lateinit var _buttonTutorial: TextView;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -38,6 +36,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError = findViewById(R.id.text_error);
_buttonCancel = findViewById(R.id.button_cancel);
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -62,7 +61,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm.setOnClickListener {
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
0 -> CastProtocolType.FASTCAST
0 -> CastProtocolType.FCAST
1 -> CastProtocolType.CHROMECAST
2 -> CastProtocolType.AIRPLAY
else -> {
@@ -105,6 +104,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
performDismiss();
};
_buttonTutorial.setOnClickListener {
UIDialogs.showCastingTutorialDialog(context)
dismiss()
}
}
override fun show() {
@@ -0,0 +1,63 @@
package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.FCastGuideActivity
import com.futo.platformplayer.activities.PolycentricWhyActivity
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.buttons.BigButton
class CastingHelpDialog(context: Context?) : AlertDialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_help, null));
findViewById<BigButton>(R.id.button_guide).onClick.subscribe {
context.startActivity(Intent(context, FCastGuideActivity::class.java))
}
findViewById<BigButton>(R.id.button_video).onClick.subscribe {
try {
//TODO: Replace the URL with the casting video URL
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
dismiss()
UIDialogs.showCastingAddDialog(context)
}
}
companion object {
private val TAG = "CastingTutorialDialog";
}
}
@@ -118,7 +118,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
msg = comment,
rating = RatingLikeDislikes(0, 0),
date = OffsetDateTime.now(),
reference = eventPointer.toReference()
eventPointer = eventPointer
));
dismiss();
@@ -1,24 +1,33 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.QRCaptureActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button;
private lateinit var _buttonScanQR: Button;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
@@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add);
_buttonScanQR = findViewById(R.id.button_scan_qr);
_recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
@@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
UIDialogs.showCastingAddDialog(context);
dismiss();
};
val c = ownerActivity
if (c is MainActivity) {
_buttonScanQR.visibility = View.VISIBLE
_buttonScanQR.setOnClickListener {
c.showUrlQrCodeScanner()
dismiss()
};
} else {
_buttonScanQR.visibility = View.GONE
}
}
override fun show() {
@@ -16,9 +16,7 @@ import com.futo.platformplayer.casting.*
import com.futo.platformplayer.states.StateApp
import com.google.android.material.slider.Slider
import com.google.android.material.slider.Slider.OnChangeListener
import com.google.android.material.slider.Slider.OnSliderTouchListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
@@ -105,7 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FastCastCastingDevice) {
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
}
@@ -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
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.logging.Logger
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.api.http.ManagedHttpClient
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.other.IStreamMetaDataSource
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.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
import kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable
class VideoDownload {
@@ -137,7 +149,7 @@ class VideoDownload {
return items.joinToString("");
}
suspend fun prepare() {
suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]");
if(video == null && videoDetails == null)
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());
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");
if(vsource != null) {
if (vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource);
videoSource = VideoUrlSource.fromUrlSource(vsource)
else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
}
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
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null)
audioSource = null;
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource);
audioSource = AudioUrlSource.fromUrlSource(asource)
else
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");
}
}
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}]");
if(videoDetails == null || (videoSource == null && audioSource == null))
throw IllegalStateException("Missing information for download to complete");
@@ -199,7 +253,7 @@ class VideoDownload {
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
}
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;
}
if(subtitleSource != null) {
@@ -217,7 +271,8 @@ class VideoDownload {
if(videoSource != null) {
sourcesToDownload.add(async {
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) {
lastVideoLength = length;
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) {
sourcesToDownload.add(async {
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) {
lastAudioLength = length;
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) {
@@ -279,7 +345,105 @@ class VideoDownload {
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())
targetFile.delete();
@@ -472,8 +636,10 @@ class VideoDownload {
val expectedFile = File(videoFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Video file missing after download");
if(expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
if (videoSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSource != null) {
if(audioFilePath == null)
@@ -481,8 +647,10 @@ class VideoDownload {
val expectedFile = File(audioFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Audio file missing after download");
if(expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
if (audioSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
}
}
if(subtitleSource != null) {
if(subtitleFilePath == null)
@@ -560,7 +728,7 @@ class VideoDownload {
const val GROUP_PLAYLIST = "Playlist";
fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4"))
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4";
else if (container.contains("application/x-mpegURL"))
return "m3u8";
@@ -585,6 +753,8 @@ class VideoDownload {
return "mp3";
else if (container.contains("audio/webm"))
return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
else
return "audio";
}
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.getOrThrow
import kotlinx.coroutines.CoroutineScope
import java.net.SocketTimeoutException
import kotlin.streams.asSequence
import kotlin.streams.toList
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);
else
return@map it.first.request(it.second.method, it.second.url, it.second.headers);
}.toList();
}
.asSequence()
.toList();
}
}
@@ -24,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.IReplacerPager
import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
@@ -32,6 +31,7 @@ import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
@@ -78,7 +78,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
val livePager = getContentPager(it);
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
StateCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
}).success { livePager ->
setLoading(false);
@@ -101,12 +101,8 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
return@TaskHandler it.getResults();
}).success {
setLoading(false);
if (it.isEmpty()) {
return@success;
}
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);
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); };
}.exception<Throwable> {
@@ -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(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(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 }, {
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
@@ -35,6 +35,11 @@ class BuyFragment : MainFragment() {
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView()
_view = null
}
class BuyView: LinearLayout {
private val _fragment: BuyFragment;
@@ -54,6 +54,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import okhttp3.internal.platform.Platform
@Serializable
data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>);
@@ -298,7 +299,7 @@ class ChannelFragment : MainFragment() {
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.id);
loadPolycentricProfile(parameter.id, parameter.url)
};
_url = parameter.url;
@@ -311,7 +312,7 @@ class ChannelFragment : MainFragment() {
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.channel.id);
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
};
_url = parameter.channel.url;
@@ -327,6 +328,18 @@ class ChannelFragment : MainFragment() {
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex));
}
private fun loadPolycentricProfile(id: PlatformID, url: String) {
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = true)
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(id);
}
} else {
_taskLoadPolycentricProfile.run(id);
}
}
private fun setLoading(isLoading: Boolean) {
if (_isLoading == isLoading) {
return;
@@ -437,18 +450,17 @@ class ChannelFragment : MainFragment() {
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
setPolycentricProfile(null, animate = false);
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false);
} else {
setPolycentricProfile(null, animate = false);
or();
}
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
val dp_35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
@@ -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
}
}
}
}
@@ -168,7 +168,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentClicked(content: IPlatformContent, time: Long) {
if(content is IPlatformVideo) {
if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.addToQueue(content)
StatePlayer.instance.insertToQueue(content, true);
} else {
if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
@@ -6,8 +6,10 @@ import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.Spinner
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -21,9 +23,13 @@ class CreatorsFragment : MainFragment() {
private var _spinnerSortBy: Spinner? = 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 {
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));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
@@ -44,6 +50,10 @@ class CreatorsFragment : MainFragment() {
_spinnerSortBy = spinnerSortBy;
_editSearch?.addTextChangedListener {
adapter.query = it.toString();
}
val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions);
recyclerView.adapter = adapter;
recyclerView.layoutManager = LinearLayoutManager(view.context);
@@ -54,6 +64,8 @@ class CreatorsFragment : MainFragment() {
super.onDestroyMainView();
_spinnerSortBy = null;
_overlayContainer = null;
_editSearch = null;
_containerSearch = null;
}
companion object {
@@ -13,7 +13,6 @@ import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.futo.platformplayer.*
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.structures.*
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.logging.Logger
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.TagsView
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
@@ -64,6 +62,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val fragment: TFragment;
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) {
this.fragment = fragment;
@@ -122,7 +121,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
@@ -134,23 +132,12 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}).success {
setLoading(false);
if (it.isEmpty()) {
return@success;
}
val posBefore = recyclerData.results.size;
val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
if(filteredResults.isEmpty()) {
filteredNextPageCounter++
if(filteredNextPageCounter <= 4)
loadNextPage()
}
else {
filteredNextPageCounter = 0;
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
ensureEnoughContentVisible(filteredResults)
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -170,8 +157,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
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();
}
}
@@ -180,6 +169,33 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_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?) {
_textCentered.text = text;
}
@@ -352,6 +368,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
private fun loadPagerInternal(pager: TPager, cache: ItemCache<TResult>? = null) {
Logger.i(TAG, "Setting new internal pager on feed");
_cache = cache;
detachPagerEvents();
@@ -369,6 +386,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
ensureEnoughContentVisible(filteredResults)
}
private fun detachPagerEvents() {
@@ -397,6 +415,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
}
var _lastNextPage = false;
private fun loadNextPage() {
synchronized(_pager_lock) {
val pager: TPager = recyclerData.pager ?: return;
@@ -405,9 +424,14 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
//loadCachedPage();
if (pager.hasMorePages()) {
_lastNextPage = true;
setLoading(true);
_nextPageHandler.run(pager);
}
else if(_lastNextPage) {
Logger.i(TAG, "End of page reached");
_lastNextPage = false;
}
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
@@ -8,87 +9,285 @@ import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.HistoryListAdapter
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class HistoryFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _adapter: HistoryListAdapter? = null;
private var _view: HistoryView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_history, container, false);
val inputMethodManager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
val recyclerHistory = view.findViewById<RecyclerView>(R.id.recycler_history);
val clearSearch = view.findViewById<ImageButton>(R.id.button_clear_search);
val editSearch = view.findViewById<EditText>(R.id.edit_search);
var tagsView = view.findViewById<TagsView>(R.id.tags_text);
tagsView.setPairs(listOf(
Pair(getString(R.string.last_hour), 60L),
Pair(getString(R.string.last_24_hours), 24L * 60L),
Pair(getString(R.string.last_week), 7L * 24L * 60L),
Pair(getString(R.string.last_30_days), 30L * 24L * 60L),
Pair(getString(R.string.last_year), 365L * 30L * 24L * 60L),
Pair(getString(R.string.all_time), -1L)));
val adapter = HistoryListAdapter();
adapter.onClick.subscribe { v ->
val diff = v.video.duration - v.position;
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
StatePlayer.instance.clearQueue();
navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
editSearch.clearFocus();
inputMethodManager.hideSoftInputFromWindow(editSearch.windowToken, 0);
};
_adapter = adapter;
recyclerHistory.adapter = adapter;
recyclerHistory.isSaveEnabled = false;
recyclerHistory.layoutManager = LinearLayoutManager(context);
tagsView.onClick.subscribe { timeMinutesToErase ->
UIDialogs.showConfirmationDialog(requireContext(), getString(R.string.are_you_sure_delete_historical), {
StatePlaylists.instance.removeHistoryRange(timeMinutesToErase.second as Long);
UIDialogs.toast(view.context, timeMinutesToErase.first + " " + getString(R.string.removed));
adapter.updateFilteredVideos();
adapter.notifyDataSetChanged();
});
};
clearSearch.setOnClickListener {
editSearch.text.clear();
clearSearch.visibility = View.GONE;
adapter.setQuery("");
editSearch.clearFocus();
inputMethodManager.hideSoftInputFromWindow(editSearch.windowToken, 0);
};
editSearch.addTextChangedListener { _ ->
val text = editSearch.text;
clearSearch.visibility = if (text.isEmpty()) { View.GONE } else { View.VISIBLE };
adapter.setQuery(text.toString());
};
val view = HistoryView(this, inflater);
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_adapter?.cleanup();
_adapter = null;
_view = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
_view?.setPager(StateHistory.instance.getHistoryPager());
}
@SuppressLint("ViewConstructor")
class HistoryView : LinearLayout {
private val _fragment: HistoryFragment;
private val _adapter: InsertedViewAdapterWithLoader<HistoryListViewHolder>;
private val _recyclerHistory: RecyclerView;
private val _clearSearch: ImageButton;
private val _editSearch: EditText;
private val _tagsView: TagsView;
private val _llmHistory: LinearLayoutManager;
private val _pagerLock = Object();
private var _nextPageHandler: TaskHandler<IPager<HistoryVideo>, List<HistoryVideo>>;
private var _pager: IPager<HistoryVideo>? = null;
private val _results = arrayListOf<HistoryVideo>();
private var _loading = false;
private var _automaticNextPageCounter = 0;
constructor(fragment: HistoryFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
inflater.inflate(R.layout.fragment_history, this);
_recyclerHistory = findViewById(R.id.recycler_history);
_clearSearch = findViewById(R.id.button_clear_search);
_editSearch = findViewById(R.id.edit_search);
_tagsView = findViewById(R.id.tags_text);
_tagsView.setPairs(listOf(
Pair(context.getString(R.string.last_hour), 60L),
Pair(context.getString(R.string.last_24_hours), 24L * 60L),
Pair(context.getString(R.string.last_week), 7L * 24L * 60L),
Pair(context.getString(R.string.last_30_days), 30L * 24L * 60L),
Pair(context.getString(R.string.last_year), 365L * 30L * 24L * 60L),
Pair(context.getString(R.string.all_time), -1L)
));
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
{ _results.size },
{ view, _ ->
val holder = HistoryListViewHolder(view);
holder.onRemove.subscribe(::onHistoryVideoRemove);
holder.onClick.subscribe(::onHistoryVideoClick);
return@InsertedViewAdapterWithLoader holder;
},
{ viewHolder, position ->
var watchTime: String? = null;
if (position == 0) {
watchTime = _results[position].date.toHumanNowDiffStringMinDay();
} else {
val previousWatchTime = _results[position - 1].date.toHumanNowDiffStringMinDay();
val currentWatchTime = _results[position].date.toHumanNowDiffStringMinDay();
if (previousWatchTime != currentWatchTime) {
watchTime = currentWatchTime;
}
}
viewHolder.bind(_results[position], watchTime);
}
);
_recyclerHistory.adapter = _adapter;
_recyclerHistory.isSaveEnabled = false;
_llmHistory = LinearLayoutManager(context);
_recyclerHistory.layoutManager = _llmHistory;
_tagsView.onClick.subscribe { timeMinutesToErase ->
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_delete_historical), {
StateHistory.instance.removeHistoryRange(timeMinutesToErase.second as Long);
UIDialogs.toast(context, timeMinutesToErase.first + " " + context.getString(R.string.removed));
updatePager();
});
};
_clearSearch.setOnClickListener {
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_editSearch.text.clear();
_clearSearch.visibility = View.GONE;
setPager(StateHistory.instance.getHistoryPager());
_editSearch.clearFocus();
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
};
_editSearch.addTextChangedListener { _ ->
val text = _editSearch.text;
_clearSearch.visibility = if (text.isEmpty()) { View.GONE } else { View.VISIBLE };
updatePager();
};
_recyclerHistory.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy);
val visibleItemCount = _recyclerHistory.childCount;
val firstVisibleItem = _llmHistory.findFirstVisibleItemPosition();
Logger.i(TAG, "onScrolled _loading = $_loading, firstVisibleItem = $firstVisibleItem, visibleItemCount = $visibleItemCount, _results.size = ${_results.size}")
val visibleThreshold = 15;
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size && firstVisibleItem > 0) {
loadNextPage();
}
}
});
_nextPageHandler = TaskHandler<IPager<HistoryVideo>, List<HistoryVideo>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
else
it.nextPage();
return@TaskHandler it.getResults();
}).success {
setLoading(false);
val posBefore = _results.size;
_results.addAll(it);
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size);
ensureEnoughContentVisible(it)
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
loadNextPage();
});
};
}
private fun updatePager() {
val query = _editSearch.text.toString();
if (_editSearch.text.isNotEmpty()) {
setPager(StateHistory.instance.getHistorySearchPager(query));
//setPager(StateHistory.instance.getHistorySearchPager(query));
} else {
setPager(StateHistory.instance.getHistoryPager());
}
}
fun setPager(pager: IPager<HistoryVideo>) {
Logger.i(TAG, "setPager()");
synchronized(_pagerLock) {
loadPagerInternal(pager);
}
}
private fun onHistoryVideoRemove(v: HistoryVideo) {
val index = _results.indexOf(v);
if (index == -1) {
return;
}
StateHistory.instance.removeHistory(v.video.url);
_results.removeAt(index);
_adapter.notifyItemRemoved(index);
}
private fun onHistoryVideoClick(v: HistoryVideo) {
val index = _results.indexOf(v);
if (index == -1) {
return;
}
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
val diff = v.video.duration - v.position;
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
StatePlayer.instance.clearQueue();
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
_editSearch.clearFocus();
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
_fragment.lifecycleScope.launch(Dispatchers.Main) {
delay(2000)
updatePager()
}
}
private fun loadNextPage() {
synchronized(_pagerLock) {
val pager: IPager<HistoryVideo> = _pager ?: return;
val hasMorePages = pager.hasMorePages();
Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages");
if (pager.hasMorePages()) {
setLoading(true);
_nextPageHandler.run(pager);
}
}
}
private fun setLoading(loading: Boolean) {
Logger.v(TAG, "setLoading loading=${loading}");
_loading = loading;
_adapter.setLoading(loading);
}
private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
Logger.i(TAG, "Setting new internal pager on feed");
_results.clear();
val toAdd = pager.getResults();
_results.addAll(toAdd);
_adapter.notifyDataSetChanged();
ensureEnoughContentVisible(toAdd)
_pager = pager;
}
private fun ensureEnoughContentVisible(results: List<HistoryVideo>) {
val canScroll = if (_results.isEmpty()) false else {
val layoutManager = _llmHistory
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
val itemHeight = firstVisibleView?.height ?: 0
val occupiedSpace = _results.size * itemHeight
val recyclerViewHeight = _recyclerHistory.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 || results.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter <= 4)
loadNextPage()
} else {
_automaticNextPageCounter = 0;
}
}
}
companion object {
fun newInstance() = HistoryFragment().apply {}
private const val TAG = "HistoryFragment"
}
}
@@ -224,7 +224,7 @@ class PostDetailFragment : MainFragment {
updateCommentType(false);
};
_commentsList.onClick.subscribe { c ->
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@@ -233,7 +233,7 @@ class PostDetailFragment : MainFragment {
if (c is PolycentricPlatformComment) {
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) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@@ -241,7 +241,7 @@ class PostDetailFragment : MainFragment {
parentComment = newComment;
});
} 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);
@@ -596,9 +596,12 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricProfile() {
val author = _post?.author ?: _postOverview?.author ?: return;
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(author.id);
}
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id);
@@ -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.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
@@ -40,6 +40,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
class SubscriptionsFeedFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -132,8 +134,10 @@ class SubscriptionsFeedFragment : MainFragment() {
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
loadResults(false);
else if(recyclerData.results.size == 0)
else if(recyclerData.results.size == 0) {
loadCache();
setLoading(false);
}
}
val announcementsView = _announcementsView;
@@ -306,12 +310,21 @@ class SubscriptionsFeedFragment : MainFragment() {
private fun loadCache() {
Logger.i(TAG, "Subscriptions load cache");
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
val results = cachePager.getResults();
Logger.i(TAG, "Subscriptions show cache (${results.size})");
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setPager(cachePager);
fragment.lifecycleScope.launch(Dispatchers.IO) {
val cachePager: IPager<IPlatformContent>;
Logger.i(TAG, "Subscriptions retrieving cache");
val time = measureTimeMillis {
cachePager = StateCache.instance.getSubscriptionCachePager();
}
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) {
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.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -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.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
@@ -60,7 +58,6 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
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.stores.FragmentedStorage
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.behavior.TouchInterceptFrameLayout
import com.futo.platformplayer.views.casting.CastView
@@ -109,7 +107,6 @@ import java.time.OffsetDateTime
import kotlin.collections.ArrayList
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.streams.toList
class VideoDetailView : ConstraintLayout {
@@ -126,6 +123,7 @@ class VideoDetailView : ConstraintLayout {
private set;
var videoLocal: VideoLocal? = null;
private var _playbackTracker: IPlaybackTracker? = null;
private var _historyIndex: DBHistory.Index? = null;
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
@@ -467,6 +465,8 @@ class VideoDetailView : ConstraintLayout {
nextVideo();
};
_player.onDatasourceError.subscribe(::onDataSourceError);
_player.onNext.subscribe { nextVideo(true) };
_player.onPrevious.subscribe { previousVideo(true) };
_minimize_controls_play.setOnClickListener { handlePlay(); };
_minimize_controls_pause.setOnClickListener { handlePause(); };
@@ -544,7 +544,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo(true) };
MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() };
MediaControlReceiver.onPreviousReceived.subscribe(this) { previousVideo(true) };
MediaControlReceiver.onCloseReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
onClose.emit()
@@ -578,7 +578,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_current = _container_content_main;
_commentsList.onClick.subscribe { c ->
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@@ -587,7 +587,7 @@ class VideoDetailView : ConstraintLayout {
if (c is PolycentricPlatformComment) {
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) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@@ -595,7 +595,7 @@ class VideoDetailView : ConstraintLayout {
parentComment = newComment;
});
} 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);
};
@@ -773,6 +773,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
@@ -996,14 +1005,17 @@ class VideoDetailView : ConstraintLayout {
_descriptionContainer.visibility = View.GONE;
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
_channelName.text = video.author.name;
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(video.author.id);
}
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
_channelName.text = video.author.name;
}
_player.clear();
@@ -1121,7 +1133,7 @@ class VideoDetailView : ConstraintLayout {
_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);
//UI
@@ -1150,7 +1162,7 @@ class VideoDetailView : ConstraintLayout {
setDescription(video.description.fixHtmlLinks());
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
} else {
@@ -1275,24 +1287,30 @@ class VideoDetailView : ConstraintLayout {
updateQueueState();
_historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, false, (toResume.toFloat() / 1000.0f).toLong());
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)}";
fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail);
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
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;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
}
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
@@ -1516,25 +1534,63 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay = _overlay_quality_selector;
}
fun prevVideo() {
Logger.i(TAG, "prevVideo")
val next = StatePlayer.instance.prevQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) {
setVideoOverview(next);
private fun getPreviousVideo(withoutRemoval: Boolean, forceLoop: Boolean = false): IPlatformVideo? {
if (!StatePlayer.instance.hasQueue) {
if (forceLoop) {
return StatePlayer.instance.currentVideo
} else {
return null
}
}
val shouldNotRemove = _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9
var previous = StatePlayer.instance.prevQueueItem(withoutRemoval || shouldNotRemove);
if(previous == null && forceLoop)
previous = StatePlayer.instance.getQueue().last();
return previous;
}
private fun getNextVideo(withoutRemoval: Boolean, forceLoop: Boolean = false): IPlatformVideo? {
if (!StatePlayer.instance.hasQueue) {
if (forceLoop) {
return StatePlayer.instance.currentVideo
} else {
return null
}
}
val shouldNotRemove = _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || shouldNotRemove);
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
return next;
}
fun previousVideo(forceLoop: Boolean = false): Boolean {
Logger.i(TAG, "previousVideo")
val previous = getPreviousVideo(false, forceLoop);
if(previous != null) {
setVideoOverview(previous);
return true;
} else {
StatePlayer.instance.setCurrentlyPlaying(null);
}
return false;
}
fun nextVideo(forceLoop: Boolean = false): Boolean {
Logger.i(TAG, "nextVideo")
var next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
val next = getNextVideo(false, forceLoop);
if(next != null) {
setVideoOverview(next);
return true;
}
else
} else {
StatePlayer.instance.setCurrentlyPlaying(null);
}
return false;
}
@@ -1674,6 +1730,10 @@ class VideoDetailView : ConstraintLayout {
private fun updateQueueState() {
_upNext.update();
/*_player.updateNextPrevious(
getPreviousVideo(withoutRemoval = true, forceLoop = true) != null,
getNextVideo(withoutRemoval = true, forceLoop = true) != null
)*/
}
//Handlers
@@ -2088,7 +2148,10 @@ class VideoDetailView : ConstraintLayout {
val v = video ?: return;
val currentTime = System.currentTimeMillis();
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;
}
@@ -2173,6 +2236,11 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
val username = cachedPolycentricProfile?.profile?.systemState?.username
if (username != null) {
_channelName.text = username
}
_monetization.setPolycentricProfile(cachedPolycentricProfile, animate);
}
@@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
import android.net.Uri
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.sources.HLSManifestSource
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.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.IVideoUrlSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
@@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
class VideoHelper {
companion object {
fun isDownloadable(detail: IPlatformVideoDetails) =
(detail.video.videoSources.any { isDownloadable(it) }) ||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
if (detail.video.videoSources.any { isDownloadable(it) }) {
return true
}
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(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
@@ -1,20 +1,29 @@
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.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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
class HLS {
companion object {
fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, 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")
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString()
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
@@ -33,7 +42,7 @@ class HLS {
}
line.startsWith("#EXT-X-MEDIA") -> {
mediaRenditions.add(parseMediaRendition(client, line, baseUrl))
mediaRenditions.add(parseMediaRendition(line, baseUrl))
}
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
@@ -50,27 +59,21 @@ class HLS {
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
}
fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, 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")
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
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()
?: throw Exception("Target duration not found in variant playlist")
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() ?: 0
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
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>()
var currentSegment: MediaSegment? = null
lines.forEach { line ->
lines.forEachIndexed { index, line ->
when {
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
@@ -93,9 +96,51 @@ class HLS {
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, 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 {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
@@ -123,7 +168,7 @@ class HLS {
)
}
private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition {
private fun parseMediaRendition(line: String, baseUrl: String): MediaRendition {
val attributes = parseAttributes(line)
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
return MediaRendition(
@@ -208,7 +253,23 @@ class HLS {
val video: String?,
val subtitles: 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(
val type: String?,
@@ -264,49 +325,77 @@ class HLS {
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) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-STREAM-INF:")
appendAttributes(this,
"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,
"VIDEO" to streamInfo.video,
"SUBTITLES" to streamInfo.subtitles,
"CLOSED-CAPTIONS" to streamInfo.closedCaptions
)
append("\n$url\n")
append(streamInfo.toM3U8Line())
append("$url\n")
}
}
data class VariantPlaylist(
val version: Int,
val targetDuration: Int,
val mediaSequence: Long,
val discontinuitySequence: Int,
val version: Int?,
val targetDuration: Int?,
val mediaSequence: Long?,
val discontinuitySequence: Int?,
val programDateTime: ZonedDateTime?,
val playlistType: String?,
val streamInfo: StreamInfo?,
val segments: List<Segment>
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
append("#EXT-X-VERSION:$version\n")
append("#EXT-X-TARGETDURATION:$targetDuration\n")
append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n")
append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n")
playlistType?.let {
append("#EXT-X-PLAYLIST-TYPE:$it\n")
}
programDateTime?.let {
append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n")
}
version?.let { append("#EXT-X-VERSION:$it\n") }
targetDuration?.let { append("#EXT-X-TARGETDURATION:$it\n") }
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
streamInfo?.let { append(it.toM3U8Line()) }
segments.forEach { segment ->
append(segment.toM3U8Line())
@@ -7,9 +7,9 @@ import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.google.protobuf.ByteString
@@ -19,17 +19,21 @@ import java.nio.ByteBuffer
import java.time.OffsetDateTime
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
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 _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
private val _profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
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(
SERVER,
system.toProto(),
@@ -46,7 +50,9 @@ class PolycentricCache {
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
).eventsList.map { e -> SignedEvent.fromProto(e) }
.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create()
for (signedEvent in signedProfileEvents) {
@@ -140,7 +146,7 @@ class PolycentricCache {
{ _, _ -> });
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
if (id.claimType <= 0) {
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedOwnedClaims(null);
}
@@ -150,7 +156,7 @@ class PolycentricCache {
return null
}
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@@ -160,7 +166,7 @@ class PolycentricCache {
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
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) };
}
@@ -182,13 +188,18 @@ class PolycentricCache {
}
fun getDataAsync(url: String): Deferred<ByteBuffer> {
StatePolycentric.instance.ensureEnabled()
return _batchTaskGetData.execute(url);
}
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
if (!StatePolycentric.instance.enabled) {
return CachedPolycentricProfile(null)
}
synchronized (_profileCache) {
val cached = _profileUrlCache.get(url) ?: return null;
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@@ -197,9 +208,13 @@ class PolycentricCache {
}
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
if (!StatePolycentric.instance.enabled) {
return CachedPolycentricProfile(null)
}
synchronized(_profileCache) {
val cached = _profileCache[system] ?: return null;
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@@ -208,7 +223,7 @@ class PolycentricCache {
}
suspend fun getProfileAsync(id: PlatformID): CachedPolycentricProfile? {
if (id.claimType <= 0) {
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedPolycentricProfile(null);
}
@@ -234,6 +249,10 @@ class PolycentricCache {
}
fun getProfileAsync(system: PublicKey): Deferred<CachedPolycentricProfile?> {
if (!StatePolycentric.instance.enabled) {
return _scope.async { CachedPolycentricProfile(null) };
}
Logger.i(TAG, "getProfileAsync (system: ${system})")
val def = _taskGetProfile.execute(system);
def.invokeOnCompletion {
@@ -281,6 +300,7 @@ class PolycentricCache {
private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io"
private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3;
@JvmStatic
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.video.SerializedPlatformContent
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 kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.json.*
@@ -22,7 +23,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
"MEDIA" -> SerializedPlatformVideo.serializer();
"NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
"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
@@ -30,7 +31,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
ContentType.MEDIA.value -> SerializedPlatformVideo.serializer();
ContentType.NESTED_VIDEO.value -> SerializedPlatformNestedContent.serializer();
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}")
};
}
@@ -162,6 +162,8 @@ class DownloadService : Service() {
Logger.i(TAG, "doDownloading - Ending Downloads");
stopService(this);
}
private suspend fun doDownload(download: VideoDownload) {
if(!Settings.instance.downloads.shouldDownload())
throw IllegalStateException("Downloading disabled on current network");
@@ -183,14 +185,14 @@ class DownloadService : Service() {
Logger.i(TAG, "Preparing [${download.name}] started");
if(download.state == VideoDownload.State.PREPARING)
download.prepare();
download.prepare(_client);
download.changeState(VideoDownload.State.DOWNLOADING);
notifyDownload(download);
var lastNotifyTime: Long = 0L;
Logger.i(TAG, "Downloading [${download.name}] started");
//TODO: Use plugin client?
download.download(_client) { progress ->
download.download(applicationContext, _client) { progress ->
download.progress = progress;
val currentTime = System.currentTimeMillis();
@@ -23,6 +23,7 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.activities.MainActivity
@@ -49,6 +50,7 @@ class MediaPlaybackService : Service() {
private var _mediaSession: MediaSessionCompat? = null;
private var _hasFocus: Boolean = false;
private var _focusRequest: AudioFocusRequest? = null;
private var _audioFocusLossTime_ms: Long? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Logger.v(TAG, "onStartCommand");
@@ -335,16 +337,32 @@ class MediaPlaybackService : Service() {
//Do not start playing on gaining audo focus
//MediaControlReceiver.onPlayReceived.emit();
_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 -> {
MediaControlReceiver.onPauseReceived.emit();
_audioFocusLossTime_ms = System.currentTimeMillis()
Log.i(TAG, "Audio focus transient loss");
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.i(TAG, "Audio focus transient loss, can duck");
}
AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusLossTime_ms = System.currentTimeMillis()
_hasFocus = false;
MediaControlReceiver.onPauseReceived.emit();
Log.i(TAG, "Audio focus lost");
@@ -1,6 +1,7 @@
package com.futo.platformplayer.states
import android.content.Context
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
@@ -256,9 +257,6 @@ class StateAnnouncement {
}
fun registerDidYouKnow() {
val random = Random();
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 {
private var _instance: StateAnnouncement? = null;
val instance: StateAnnouncement
@@ -1,24 +1,19 @@
package com.futo.platformplayer.states
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import android.util.Xml
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -28,12 +23,11 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
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.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
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.LogLevel
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.services.DownloadService
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.stripe.android.core.utils.encodeToJson
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
/***
* This class contains global context for unconventional cases where obtaining context is hard.
@@ -66,20 +63,6 @@ import kotlin.time.measureTime
class StateApp {
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? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
@@ -235,14 +218,33 @@ class StateApp {
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) {
val intent = Intent(Intent.ACTION_OPEN_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) {
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) {
if(it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data;
@@ -408,7 +410,7 @@ class StateApp {
try {
Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]");
val time = measureTimeMillis {
ChannelContentCache.instance;
StateCache.instance;
}
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
} catch (e: Throwable) {
@@ -543,9 +545,16 @@ class StateApp {
);
}
StateAnnouncement.instance.registerDefaultHandlerAnnouncement();
StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished");
StatePlaylists.instance.toMigrateCheck();
if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory();
}
fun mainAppStartedWithExternalFiles(context: Context) {
if(!Settings.instance.didFirstStart) {
if(StateBackup.hasAutomaticBackup()) {
@@ -8,17 +8,21 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.writeBytes
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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;
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(
StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck()
@@ -192,7 +187,19 @@ class StateBackup {
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 now = OffsetDateTime.now();
val exportFile = File(
@@ -401,6 +408,46 @@ class StateBackup {
).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(
@@ -0,0 +1,183 @@
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> {
return _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) { it.obj }
}
fun getAllChannelCachePager(channelUrls: List<String>): IPager<IPlatformContent> {
return _subscriptionCache.queryInPager(DBSubscriptionCache.Index::channelUrl, channelUrls, 20) { it.obj }
}
fun getChannelCachePager(channelUrls: List<String>, pageSize: Int = 20): IPager<IPlatformContent> {
val pagers = MultiChronoContentPager(channelUrls.map { _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, it, pageSize) {
it.obj;
} }, false, pageSize);
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));
}
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::name, "%${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}")
.setSubText(content.datetime?.toHumanNowDiffStringMinDay())
.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))
.setChannelId(notificationChannel.id);
if(thumbnail != null) {
@@ -40,8 +40,10 @@ import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.*
import kotlinx.coroutines.*
import okhttp3.internal.concat
import java.lang.Thread.sleep
import java.time.OffsetDateTime
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
import kotlin.streams.asSequence
import kotlin.streams.toList
/***
@@ -389,6 +391,7 @@ class StatePlatform {
}
return@map homeResult;
}
.asSequence()
.toList()
.associateWith { 1f };
@@ -403,7 +406,12 @@ class StatePlatform {
val deferred: List<Pair<IPlatformClient, Deferred<IPager<IPlatformContent>?>>> = clients.map {
return@map Pair(it, scope.async(Dispatchers.IO) {
try {
val searchResult = it.fromPool(_pagerClientPool).getHome();
var searchResult = it.fromPool(_pagerClientPool).getHome();
if(searchResult.getResults().size == 0) {
Logger.i(TAG, "No home results, retrying");
sleep(500);
searchResult = it.fromPool(_pagerClientPool).getHome();
}
return@async searchResult;
} catch(ex: Throwable) {
Logger.e(TAG, "getHomeRefresh", ex);
@@ -709,6 +717,7 @@ class StatePlatform {
}
return@map results;
}
.asSequence()
.toList();
val pager = MultiChronoContentPager(pagers.toTypedArray());
@@ -37,6 +37,7 @@ class StatePlayer {
//Video Status
var rotationLock : Boolean = false;
var loopVideo : Boolean = false;
val isPlaying: Boolean get() = _exoplayer?.player?.playWhenReady ?: false;
@@ -286,6 +287,31 @@ class StatePlayer {
}
onQueueChanged.emit(true);
}
fun insertToQueue(video: IPlatformVideo, playNow: Boolean = false) {
synchronized(_queue) {
if(_queue.isEmpty()) {
setQueueType(TYPE_QUEUE);
currentVideo?.let {
_queue.add(it);
}
}
if(_queue.isEmpty())
_queue.add(video);
else
_queue.add(_queuePosition.coerceAtLeast(0).coerceAtMost(_queue.size - 1), video);
if (queueShuffle) {
addToShuffledQueue(video);
}
if (_queuePosition < 0) {
_queuePosition = 0;
}
}
onQueueChanged.emit(true);
if(playNow)
setQueuePosition(video);
}
fun setQueuePosition(video: IPlatformVideo) {
synchronized(_queue) {
if (getCurrentQueueItem() == video) {
@@ -348,6 +374,8 @@ class StatePlayer {
}
fun getNextQueueItem() : IPlatformVideo? {
if(loopVideo)
return currentVideo;
synchronized(_queue) {
val shuffledQueue = _queueShuffled;
val queue = if (queueShuffle && shuffledQueue != null) {
@@ -375,6 +403,8 @@ class StatePlayer {
}
};
fun nextQueueItem(withoutRemoval: Boolean = false) : IPlatformVideo? {
if(loopVideo)
return currentVideo;
synchronized(_queue) {
if (_queue.isEmpty())
return null;
@@ -3,6 +3,7 @@ package com.futo.platformplayer.states
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.sqlite.db.SimpleSQLiteQuery
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
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.IPlatformVideoDetails
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.Event2
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.Playlist
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.ReconstructStore
import kotlinx.serialization.encodeToString
@@ -26,6 +30,8 @@ import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
/***
* Used to maintain playlists
@@ -39,26 +45,17 @@ class StatePlaylists {
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
})
.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")
.withRestore(PlaylistBackup())
.load();
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
var onHistoricVideoChanged = Event2<IPlatformVideo, Long>();
val onWatchLaterChanged = Event0();
fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore, _historyStore);
return listOf(playlistStore, _watchlistStore);
}
fun getWatchLater() : List<SerializedPlatformVideo> {
synchronized(_watchlistStore) {
return _watchlistStore.getItems();
@@ -99,6 +96,7 @@ class StatePlaylists {
return playlistStore.findItem { it.id == id };
}
fun didPlay(playlistId: String) {
val playlist = getPlaylist(playlistId);
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 {
val channel = StatePlatform.instance.getChannel(channelUrl).await();
return createPlaylistFromChannel(channel, onPage);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
import android.content.Context
import android.content.Intent
import android.util.Log
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
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.PolycentricPlatformComment
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.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
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.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@@ -38,11 +34,11 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import kotlin.Exception
class StatePolycentric {
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;
private var _likeDislikeMap = hashMapOf<String, LikeDislikeEntry>()
private val _activeProcessHandle = FragmentedStorage.get<StringStorage>("activeProcessHandle");
private var _transientEnabled = true
val enabled get() = _transientEnabled && Settings.instance.other.polycentricEnabled
fun load(context: Context) {
val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
if (!enabled) {
return
}
val activeProcessHandleString = _activeProcessHandle.value;
if (activeProcessHandleString.isNotEmpty()) {
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
try {
val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
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> {
if (!enabled) {
return listOf()
}
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); };
}
fun setProcessHandle(processHandle: ProcessHandle?) {
ensureEnabled()
this.processHandle = processHandle;
if (processHandle != null) {
@@ -96,20 +124,34 @@ class StatePolycentric {
}
fun updateLikeMap(ref: Protocol.Reference, hasLiked: Boolean, hasDisliked: Boolean) {
ensureEnabled()
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
}
fun hasDisliked(ref: Protocol.Reference): Boolean {
if (!enabled) {
return false
}
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
return entry.hasDisliked;
}
fun hasLiked(ref: Protocol.Reference): Boolean {
if (!enabled) {
return false
}
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
return entry.hasLiked;
}
fun requireLogin(context: Context, text: String, action: (processHandle: ProcessHandle) -> Unit) {
if (!enabled) {
UIDialogs.toast(context, "Polycentric is disabled")
return
}
val p = processHandle;
if (p == null) {
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> {
if (!enabled) {
return listOf(url);
}
var polycentricProfile: PolycentricProfile? = null;
try {
@@ -172,7 +200,10 @@ class StatePolycentric {
else
return listOf(url);
}
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
ensureEnabled()
//TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
@@ -212,13 +243,78 @@ class StatePolycentric {
StatePlatform.instance.getEnabledClients().map { it.id }
);*/
}
suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> {
return withContext(Dispatchers.IO) {
getChannelContent(this, profile) ?: EmptyPager();
fun getSystemComments(context: Context, system: PublicKey): List<IPlatformComment> {
if (!enabled) {
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> {
if (!enabled) {
return EmptyPager()
}
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
Protocol.QueryReferencesRequestEvents.newBuilder()
.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 {
val sev = SignedEvent.fromProto(it.event);
val ev = sev.event;
@@ -294,7 +390,6 @@ class StatePolycentric {
try {
val post = Protocol.Post.parseFrom(ev.content);
val id = ev.system.toProto().key.toByteArray().toBase64();
val likes = it.countsList[0];
val dislikes = it.countsList[1];
val replies = it.countsList[2];
@@ -338,7 +433,7 @@ class StatePolycentric {
rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(),
reference = sev.toPointer().toReference()
eventPointer = sev.toPointer()
);
} catch (e: Throwable) {
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.structures.*
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.Event1
import com.futo.platformplayer.constructs.Event2
@@ -39,6 +38,7 @@ import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.streams.asSequence
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
@@ -259,7 +259,9 @@ class StateSubscriptions {
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
else
Pair(it, listOf(it.channel.url));
}.toList().associate { it };
}.asSequence()
.toList()
.associate { it };
val result = algo.getSubscriptions(subUrls);
return Pair(result.pager, result.exceptions);
@@ -2,6 +2,8 @@ package com.futo.platformplayer.stores
import com.futo.platformplayer.Settings
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.ManagedStore
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,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
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
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();
}
}
}
@@ -1,39 +1,23 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
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.cache.ChannelContentCache
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toSafeFileName
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ForkJoinPool
class CachedSubscriptionAlgorithm(pageSize: Int = 150, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, threadPool: ForkJoinPool? = null)
class CachedSubscriptionAlgorithm(scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, threadPool: ForkJoinPool? = null, pageSize: Int = 50)
: SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
private val _pageSize: Int = pageSize;
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
return mapOf<JSClient, Int>();
return mapOf();
}
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet();
val validStores = ChannelContentCache.instance._channelContents
.filter { validSubIds.contains(it.key) }
.map { it.value };
val items = validStores.flatMap { it.getItems() }
.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(), _pageSize), 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.IPager
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.ScriptCaptchaRequiredException
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.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
@@ -157,7 +157,7 @@ class SimpleSubscriptionAlgorithm(
val time = measureTimeMillis {
pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore);
pager = ChannelContentCache.cachePagerResults(scope, pager!!) {
pager = StateCache.cachePagerResults(scope, pager!!) {
onNewCacheHit.emit(sub, it);
};
@@ -176,7 +176,7 @@ class SimpleSubscriptionAlgorithm(
throw channelEx;
else {
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);
}
}
}
@@ -38,7 +38,7 @@ abstract class SubscriptionFetchAlgorithm(
fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm {
return when(algo) {
SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(150, scope, allowFailure, withCacheFallback, pool);
SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50);
SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
else -> throw IllegalStateException("Unknown algorithm ${algo}");
@@ -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.IPager
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.ScriptCaptchaRequiredException
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.models.Subscription
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
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 liveTasks = 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);
}) 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);
else {
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;
else {
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;
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
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
@@ -11,9 +12,10 @@ import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import com.futo.platformplayer.R
class Loader : LinearLayout {
class LoaderView : LinearLayout {
private val _imageLoader: ImageView;
private val _automatic: Boolean;
private var _isWhite: Boolean;
private val _animatable: Animatable;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -24,18 +26,25 @@ class Loader : LinearLayout {
if (attrs != null) {
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0);
_automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false);
_isWhite = attrArr.getBoolean(R.styleable.LoaderView_isWhite, false);
attrArr.recycle();
} else {
_automatic = false;
_isWhite = false;
}
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);
_imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable;
_automatic = automatic;
_isWhite = isWhite;
if(height > 0) {
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
@@ -41,7 +41,7 @@ class MonetizationView : LinearLayout {
private val _textMerchandise: TextView;
private val _recyclerMerchandise: RecyclerView;
private val _loaderMerchandise: Loader;
private val _loaderViewMerchandise: LoaderView;
private val _layoutMerchandise: FrameLayout;
private var _merchandiseAdapterView: AnyAdapterView<StoreItem, StoreItemViewHolder>? = null;
@@ -81,7 +81,7 @@ class MonetizationView : LinearLayout {
_textMerchandise = findViewById(R.id.text_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);
_root = findViewById(R.id.root);
@@ -108,7 +108,7 @@ class MonetizationView : LinearLayout {
}
private fun setMerchandise(items: List<StoreItem>?) {
_loaderMerchandise.stop();
_loaderViewMerchandise.stop();
if (items == null) {
_textMerchandise.visibility = View.GONE;
@@ -147,7 +147,7 @@ class MonetizationView : LinearLayout {
val uri = Uri.parse(storeData);
if (uri.isAbsolute) {
_taskLoadMerchandise.run(storeData);
_loaderMerchandise.start();
_loaderViewMerchandise.start();
} else {
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
}
@@ -3,6 +3,7 @@ package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -37,8 +38,10 @@ class CommentViewHolder : ViewHolder {
private val _layoutRating: LinearLayout;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
private val _buttonDelete: FrameLayout;
var onClick = Event1<IPlatformComment>();
var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>();
var comment: IPlatformComment? = null
private set;
@@ -55,6 +58,7 @@ class CommentViewHolder : ViewHolder {
_buttonReplies = itemView.findViewById(R.id.button_replies);
_layoutRating = itemView.findViewById(R.id.layout_rating);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_buttonDelete = itemView.findViewById(R.id.button_delete);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
@@ -87,7 +91,12 @@ class CommentViewHolder : ViewHolder {
_buttonReplies.onClick.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);
@@ -108,7 +117,8 @@ class CommentViewHolder : ViewHolder {
val rating = comment.rating;
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 {
_layoutComment.alpha = 1.0f;
}
@@ -167,6 +177,13 @@ class CommentViewHolder : ViewHolder {
_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;
}
@@ -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) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FastCastCastingDevice) {
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
_textType.text = "FCast";
}
_textName.text = d.name;
@@ -1,109 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StatePlaylists
class HistoryListAdapter : RecyclerView.Adapter<HistoryListViewHolder> {
private lateinit var _filteredVideos: MutableList<HistoryVideo>;
val onClick = Event1<HistoryVideo>();
private var _query: String = "";
constructor() : super() {
updateFilteredVideos();
StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position ->
val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url };
if (index == -1) {
return@subscribe;
}
_filteredVideos[index].position = position;
if (index < _filteredVideos.size - 2) {
notifyItemRangeChanged(index, 2);
} else {
notifyItemChanged(index);
}
};
}
fun setQuery(query: String) {
_query = query;
updateFilteredVideos();
}
fun updateFilteredVideos() {
val videos = StatePlaylists.instance.getHistory();
if (_query.isBlank()) {
_filteredVideos = videos.toMutableList();
} else {
_filteredVideos = videos.filter { v -> v.video.name.lowercase().contains(_query); }.toMutableList();
}
notifyDataSetChanged();
}
fun cleanup() {
StatePlaylists.instance.onHistoricVideoChanged.remove(this);
}
override fun getItemCount() = _filteredVideos.size;
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): HistoryListViewHolder {
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false);
val holder = HistoryListViewHolder(view);
holder.onRemove.subscribe { v ->
val videos = _filteredVideos;
val index = videos.indexOf(v);
if (index == -1) {
return@subscribe;
}
StatePlaylists.instance.removeHistory(v.video.url);
_filteredVideos.removeAt(index);
notifyItemRemoved(index);
};
holder.onClick.subscribe { v ->
val videos = _filteredVideos;
val index = videos.indexOf(v);
if (index == -1) {
return@subscribe;
}
_filteredVideos.removeAt(index);
_filteredVideos.add(0, v);
notifyItemMoved(index, 0);
notifyItemRangeChanged(0, 2);
onClick.emit(v);
};
return holder;
}
override fun onBindViewHolder(viewHolder: HistoryListViewHolder, position: Int) {
val videos = _filteredVideos;
var watchTime: String? = null;
if (position == 0) {
watchTime = videos[position].date.toHumanNowDiffStringMinDay();
} else {
val previousWatchTime = videos[position - 1].date.toHumanNowDiffStringMinDay();
val currentWatchTime = videos[position].date.toHumanNowDiffStringMinDay();
if (previousWatchTime != currentWatchTime) {
watchTime = currentWatchTime;
}
}
viewHolder.bind(videos[position], watchTime);
}
companion object {
val TAG = "HistoryListAdapter";
}
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
@@ -35,26 +37,26 @@ class HistoryListViewHolder : ViewHolder {
val onClick = Event1<HistoryVideo>();
val onRemove = Event1<HistoryVideo>();
constructor(view: View) : super(view) {
_root = view.findViewById(R.id.root);
_imageThumbnail = view.findViewById(R.id.image_video_thumbnail);
_imageThumbnail?.clipToOutline = true;
_textName = view.findViewById(R.id.text_video_name);
_textAuthor = view.findViewById(R.id.text_author);
_textMetadata = view.findViewById(R.id.text_video_metadata);
_textVideoDuration = view.findViewById(R.id.thumbnail_duration);
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
_containerLive = view.findViewById(R.id.thumbnail_live_container);
_imageRemove = view.findViewById(R.id.image_trash);
_textHeader = view.findViewById(R.id.text_header);
_timeBar = view.findViewById(R.id.time_bar);
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false)) {
_root = itemView.findViewById(R.id.root);
_imageThumbnail = itemView.findViewById(R.id.image_video_thumbnail);
_imageThumbnail.clipToOutline = true;
_textName = itemView.findViewById(R.id.text_video_name);
_textAuthor = itemView.findViewById(R.id.text_author);
_textMetadata = itemView.findViewById(R.id.text_video_metadata);
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
_containerLive = itemView.findViewById(R.id.thumbnail_live_container);
_imageRemove = itemView.findViewById(R.id.image_trash);
_textHeader = itemView.findViewById(R.id.text_header);
_timeBar = itemView.findViewById(R.id.time_bar);
_root.setOnClickListener {
val v = video ?: return@setOnClickListener;
onClick.emit(v);
};
_imageRemove?.setOnClickListener {
_imageRemove.setOnClickListener {
val v = video ?: return@setOnClickListener;
onRemove.emit(v);
};
@@ -15,7 +15,12 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
var onClick = Event1<Subscription>();
var onSettings = Event1<Subscription>();
var sortBy: Int = 3
var sortBy: Int = 5
set(value) {
field = value
updateDataset()
}
var query: String? = null
set(value) {
field = value;
updateDataset();
@@ -53,6 +58,7 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
}
private fun updateDataset() {
val queryLower = query?.lowercase() ?: "";
_sortedDataset = when (sortBy) {
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name.lowercase() })
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name.lowercase() })
@@ -61,7 +67,9 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
4 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackSeconds }
5 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds }
else -> throw IllegalStateException("Invalid sorting algorithm selected.");
}.toList();
}
.filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) }
.toList();
notifyDataSetChanged();
}
@@ -81,17 +81,20 @@ class SubscriptionViewHolder : ViewHolder {
this.subscription = sub;
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_textName.text = sub.channel.name;
bindViewMetrics(sub);
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(sub, cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(sub.channel.id);
}
} else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id);
_textName.text = sub.channel.name;
bindViewMetrics(sub);
}
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
}
private fun onProfileLoaded(sub: Subscription?, cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
@@ -5,10 +5,12 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
@@ -17,6 +19,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.platform.PlatformIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -25,6 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView {
protected val _platformIndicatorNested: PlatformIndicator;
protected val _containerLoader: LinearLayout;
protected val _loaderView: LoaderView;
protected val _containerUnavailable: LinearLayout;
protected val _textNestedUrl: TextView;
@@ -38,8 +42,39 @@ class PreviewNestedVideoView : PreviewVideoView {
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
_containerLoader = findViewById(R.id.container_loader);
_loaderView = findViewById(R.id.loader);
_containerUnavailable = findViewById(R.id.container_unavailable);
_textNestedUrl = findViewById(R.id.text_nested_url);
_imageChannel?.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
_textChannelName.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
_textVideoMetadata.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
_button_add_to.setOnClickListener {
if(_contentNested is IPlatformVideo)
_contentNested?.let { onAddToClicked.emit(it as IPlatformVideo) }
else _content?.let {
if(it is IPlatformNestedContent)
loadNested(it) {
if(it is IPlatformVideo)
onAddToClicked.emit(it);
else
UIDialogs.toast(context, "Content is not a video");
}
}
};
_button_add_to_queue.setOnClickListener {
if(_contentNested is IPlatformVideo)
_contentNested?.let { onAddToQueueClicked.emit(it as IPlatformVideo) }
else _content?.let {
if(it is IPlatformNestedContent)
loadNested(it) {
if(it is IPlatformVideo)
onAddToQueueClicked.emit(it);
else
UIDialogs.toast(context, "Content is not a video");
}
}
};
}
override fun inflate(feedStyle: FeedStyle) {
@@ -81,6 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView {
if(!_contentSupported) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loaderView.stop();
}
else {
if(_feedStyle == FeedStyle.THUMBNAIL)
@@ -96,12 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView {
_contentSupported = false;
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loaderView.stop();
}
}
private fun loadNested(content: IPlatformNestedContent) {
private fun loadNested(content: IPlatformNestedContent, onCompleted: ((IPlatformContentDetails)->Unit)? = null) {
Logger.i(TAG, "Loading nested content [${content.contentUrl}]");
_containerLoader.visibility = View.VISIBLE;
_loaderView.start();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
def.invokeOnCompletion {
@@ -112,11 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView {
if(_content == content) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loaderView.stop();
}
//TODO: Handle exception
}
else if(_content == content) {
_containerLoader.visibility = View.GONE;
_loaderView.stop();
val nestedContent = def.getCompleted();
_contentNested = nestedContent;
if(nestedContent is IPlatformVideoDetails) {
@@ -131,6 +171,8 @@ class PreviewNestedVideoView : PreviewVideoView {
else {
_containerUnavailable.visibility = View.VISIBLE;
}
if(onCompleted != null)
onCompleted(nestedContent);
}
}
};
@@ -27,6 +27,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.others.CreatorThumbnail
@@ -175,20 +176,25 @@ open class PreviewVideoView : LinearLayout {
stopPreview();
_imageNeopassChannel?.visibility = View.GONE;
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
_imageChannel?.let {
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
}
_textChannelName.text = content.author.name
val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
} else {
_imageNeopassChannel?.visibility = View.GONE;
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
_imageChannel?.let {
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
if (cachedProfile.expired) {
_taskLoadProfile.run(content.author.id);
}
} else {
_taskLoadProfile.run(content.author.id);
_textChannelName.text = content.author.name
}
_imageChannel?.clipToOutline = true;
@@ -246,7 +252,7 @@ open class PreviewVideoView : LinearLayout {
val timeBar = _timeBar
if (timeBar != null) {
if (shouldShowTimeBar) {
val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url)
val historyPosition = StateHistory.instance.getHistoryPosition(video.url)
timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE
timeBar.progress = historyPosition.toFloat() / video.duration.toFloat()
} else {
@@ -65,13 +65,17 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
override fun bind(authorLink: PlatformAuthorLink) {
_taskLoadProfile.cancel();
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_textName.text = authorLink.name;
val cachedProfile = PolycentricCache.instance.getCachedProfile(authorLink.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(authorLink.id);
}
} else {
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.run(authorLink.id);
_textName.text = authorLink.name;
}
if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L)
@@ -51,13 +51,17 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
_channel = subscription.channel;
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_name.text = subscription.channel.name;
val cachedProfile = PolycentricCache.instance.getCachedProfile(subscription.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(subscription.channel.id);
}
} else {
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_taskLoadProfile.run(subscription.channel.id);
_name.text = subscription.channel.name;
}
_subscription = subscription;
@@ -6,7 +6,6 @@ import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
@@ -63,11 +62,15 @@ class GestureControlView : LinearLayout {
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
private val _gestureController: GestureDetectorCompat;
val onSeek = Event1<Long>();
val onBrightnessAdjusted = Event1<Float>();
val onSoundAdjusted = Event1<Float>();
val onToggleFullscreen = Event0();
var fullScreenGestureEnabled = true
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_gesture_controls, this, true);
@@ -82,17 +85,16 @@ class GestureControlView : LinearLayout {
_layoutControlsBrightness = findViewById(R.id.layout_controls_brightness);
_progressBrightness = findViewById(R.id.progress_brightness);
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
}
fun setupTouchArea(view: View, layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
val gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
_gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
override fun onDown(p0: MotionEvent): Boolean { return false; }
override fun onShowPress(p0: MotionEvent) = Unit;
override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; }
override fun onScroll(p0: MotionEvent, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
override fun onFling(p0: MotionEvent?, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; }
override fun onScroll(p0: MotionEvent?, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if(p0 == null)
return false;
if (_isFullScreen && _adjustingBrightness) {
val adjustAmount = (distanceY * 2) / height;
_brightnessFactor = (_brightnessFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
@@ -112,15 +114,14 @@ class GestureControlView : LinearLayout {
_fullScreenFactorDown = (_fullScreenFactorDown + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorDown;
} else {
val rx = p0.x / width;
val ry = p0.y / height;
Logger.v(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
val rx = (p0.x + p1.x) / (2 * width);
val ry = (p0.y + p1.y) / (2 * height);
if (ry > 0.1 && ry < 0.9) {
if (_isFullScreen && rx < 0.4) {
if (_isFullScreen && rx < 0.2) {
startAdjustingBrightness();
} else if (_isFullScreen && rx > 0.6) {
} else if (_isFullScreen && rx > 0.8) {
startAdjustingSound();
} else if (rx >= 0.4 && rx <= 0.6) {
} else if (fullScreenGestureEnabled && rx in 0.3..0.7) {
if (_isFullScreen) {
startAdjustingFullscreenDown();
} else {
@@ -132,11 +133,10 @@ class GestureControlView : LinearLayout {
return true;
}
override fun onLongPress(p0: MotionEvent) = Unit;
override fun onFling(p0: MotionEvent, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; }
override fun onLongPress(p0: MotionEvent) = Unit
});
gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
_gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (_skipping) {
return false;
@@ -166,52 +166,58 @@ class GestureControlView : LinearLayout {
}
});
val touchListener = object : OnTouchListener {
override fun onTouch(v: View?, ev: MotionEvent): Boolean {
cancelHideJob();
isClickable = true
}
if (_skipping) {
if (ev.action == MotionEvent.ACTION_UP) {
startExitFastForward();
stopAutoFastForward();
} else if (ev.action == MotionEvent.ACTION_DOWN) {
_jobExitFastForward?.cancel();
_jobExitFastForward = null;
fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
}
startAutoFastForward();
fastForwardTick();
}
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
val ev = event ?: return super.onTouchEvent(event);
if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingSound();
}
cancelHideJob();
if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingBrightness();
}
if (_skipping) {
if (ev.action == MotionEvent.ACTION_UP) {
startExitFastForward();
stopAutoFastForward();
} else if (ev.action == MotionEvent.ACTION_DOWN) {
_jobExitFastForward?.cancel();
_jobExitFastForward = null;
if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorUp > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenUp();
}
if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorDown > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenDown();
}
startHideJobIfNecessary();
return gestureController.onTouchEvent(ev);
startAutoFastForward();
fastForwardTick();
}
};
}
view.setOnTouchListener(touchListener);
view.isClickable = true;
if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingSound();
}
if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingBrightness();
}
if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorUp > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenUp();
}
if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorDown > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenDown();
}
startHideJobIfNecessary();
_gestureController.onTouchEvent(ev)
return true;
}
fun cancelHideJob() {
@@ -19,6 +19,9 @@ open class BigButton : LinearLayout {
private val _textPrimary: TextView;
private val _textSecondary: TextView;
val title: String get() = _textPrimary.text.toString();
val description: String get() = _textSecondary.text.toString();
val onClick = Event0();
constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) {
@@ -37,6 +40,8 @@ open class BigButton : LinearLayout {
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
action();
onClick.emit();
};
@@ -51,6 +56,8 @@ open class BigButton : LinearLayout {
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
onClick.emit();
};
}
@@ -141,4 +148,17 @@ open class BigButton : LinearLayout {
return this;
}
fun setButtonEnabled(enabled: Boolean) {
if(enabled) {
alpha = 1f;
isEnabled = true;
isClickable = true;
}
else {
alpha = 0.5f;
isEnabled = false;
isClickable = false;
}
}
}
@@ -26,6 +26,7 @@ class CastView : ConstraintLayout {
private val _thumbnail: ImageView;
private val _buttonMinimize: ImageButton;
private val _buttonSettings: ImageButton;
private val _buttonLoop: ImageButton;
private val _buttonPlay: ImageButton;
private val _buttonPause: ImageButton;
private val _buttonCast: CastButton;
@@ -49,6 +50,7 @@ class CastView : ConstraintLayout {
_thumbnail = findViewById(R.id.image_thumbnail);
_buttonMinimize = findViewById(R.id.button_minimize);
_buttonSettings = findViewById(R.id.button_settings);
_buttonLoop = findViewById(R.id.button_loop);
_buttonPlay = findViewById(R.id.button_play);
_buttonPause = findViewById(R.id.button_pause);
_buttonCast = findViewById(R.id.button_cast);
@@ -58,12 +60,19 @@ class CastView : ConstraintLayout {
_timeBar = findViewById(R.id.time_progress);
_background = findViewById(R.id.layout_background);
_gestureControlView = findViewById(R.id.gesture_control);
_gestureControlView.setupTouchArea(_background);
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
};
_buttonLoop.setOnClickListener {
StatePlayer.instance.loopVideo = !StatePlayer.instance.loopVideo;
_buttonLoop.setImageResource(if(StatePlayer.instance.loopVideo) R.drawable.ic_loop_active else R.drawable.ic_loop);
}
_buttonLoop.setImageResource(if(StatePlayer.instance.loopVideo) R.drawable.ic_loop_active else R.drawable.ic_loop);
_timeBar.addListener(object : OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
StateCasting.instance.videoSeekTo(position.toDouble());
@@ -28,6 +28,10 @@ class ButtonField : BigButton, IField {
override val value: Any? = null;
override val searchContent: String?
get() = "$title $description";
override val obj : Any? get() {
if(this._obj == null)
throw java.lang.IllegalStateException("Can only be called if fromField is used");
@@ -53,6 +57,8 @@ class ButtonField : BigButton, IField {
};
super.onClick.subscribe {
if(!isEnabled)
return@subscribe;
if(_method?.parameterCount == 1)
_method?.invoke(_obj, context);
else if(_method?.parameterCount == 2)

Some files were not shown because too many files have changed in this diff Show More