mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3dd854d4 | |||
| c529446219 | |||
| fa2f8c3447 | |||
| 840d1ae534 | |||
| 2530c6eb58 | |||
| ee3761c780 | |||
| e4c89e9aa9 | |||
| 9d5888ddf7 | |||
| ecc94920d7 | |||
| 5cafbf243e | |||
| f3fa208680 | |||
| 502602e27a | |||
| 5054b093a4 | |||
| 0ffaec6bc2 | |||
| ef8ea9eecf | |||
| b09d22e479 | |||
| 01787b6229 | |||
| 4c022698d3 | |||
| bfdcab0e84 | |||
| aaea5cc963 | |||
| 23d9c33406 | |||
| fad1b216df | |||
| e221b508d3 | |||
| dfafac7d99 | |||
| 2246f8cee2 | |||
| 8661ff88c0 | |||
| 0bba7fa373 | |||
| 0c1822b118 | |||
| 6df8f84421 | |||
| 7fa80ec048 | |||
| b3f9b81984 | |||
| 1393c489c1 | |||
| 640c2cbed0 | |||
| e55509f549 | |||
| 27c7fb0c12 | |||
| 88f3815585 | |||
| 2e9405cfdb | |||
| 9c1b543ed6 | |||
| d34cb0f9c1 | |||
| 116dc90d21 | |||
| 17b9853bb6 | |||
| 8bfb8abd20 | |||
| 9ee3f1f26e | |||
| 5dcff29d8d | |||
| 6cfbd0c8bf | |||
| 01d96cce16 | |||
| 58c376f011 | |||
| 439d339330 | |||
| 44eacc2a47 | |||
| 8135d61398 | |||
| 66208f8265 | |||
| f52251e23a | |||
| dbea93efe5 | |||
| 3bf0740bd1 | |||
| fa7f1b11f3 | |||
| ff914bbdf4 | |||
| b822078d4b | |||
| 290d2ceb50 | |||
| 8ec9025990 | |||
| c4cf856dcd | |||
| 38bb4e25d3 | |||
| 0de996d91c | |||
| 1f38c9b27d | |||
| 234f31b02d | |||
| 00e40e8cd6 | |||
| 0bc6a43dc1 | |||
| e7e0157fbc | |||
| 4cae1a41a5 | |||
| 4fa61e7f52 | |||
| f02ac796f5 | |||
| 22146a6bdc | |||
| 5285eae01d | |||
| c47ca369e4 | |||
| f0b1f62bb1 | |||
| f7aa6d006e | |||
| 6b67cd549f | |||
| fc6bf85822 | |||
| fbd9345cf8 | |||
| 63137b4c4d | |||
| e28dc7a3a6 | |||
| 6e14acc685 | |||
| ba64153f1d | |||
| 72c04e7556 | |||
| 54f37ee5b2 | |||
| 4fbb325313 | |||
| e1d3b95f73 | |||
| 8f7b4b8257 | |||
| 9d906025ea | |||
| d7f4dd65e8 | |||
| 599b119e62 | |||
| 41176464db | |||
| dd0ad19fb9 | |||
| 430625d2fb | |||
| 796cd1a776 | |||
| baa26af0c0 | |||
| ea0c27936e | |||
| 4aade35d19 | |||
| 251a5701af | |||
| 2da3116111 | |||
| 4c82fa1a4a | |||
| 7eef6eece2 | |||
| 570f32e980 | |||
| 16a0351125 | |||
| 2fa9005806 | |||
| 25527997fa | |||
| 4655d8369d | |||
| aeaaace3a4 | |||
| e6997004ff | |||
| 5e1896b7f2 | |||
| 88ca90c13a | |||
| f8ee340499 | |||
| 93f5260e20 |
+29
-13
@@ -1,13 +1,14 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.encryption.GEncryptionProviderV0
|
||||
import com.futo.platformplayer.encryption.GEncryptionProviderV1
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class EncryptionProviderTests {
|
||||
class GEncryptionProviderTests {
|
||||
@Test
|
||||
fun testEncryptDecrypt() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
fun testEncryptDecryptV1() {
|
||||
val encryptionProvider = GEncryptionProviderV1.instance
|
||||
val plaintext = "This is a test string."
|
||||
|
||||
// Encrypt the plaintext
|
||||
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
|
||||
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytes() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
fun testEncryptDecryptBytesV1() {
|
||||
val encryptionProvider = GEncryptionProviderV1.instance
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
|
||||
// Encrypt the plaintext
|
||||
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytesPassword() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
val password = "1234".padStart(32, '9');
|
||||
fun testEncryptDecryptV0() {
|
||||
val encryptionProvider = GEncryptionProviderV0.instance
|
||||
val plaintext = "This is a test string."
|
||||
|
||||
// Encrypt the plaintext
|
||||
val ciphertext = encryptionProvider.encrypt(bytes, password)
|
||||
val ciphertext = encryptionProvider.encrypt(plaintext)
|
||||
|
||||
// Decrypt the ciphertext
|
||||
val decrypted = encryptionProvider.decrypt(ciphertext, password)
|
||||
val decrypted = encryptionProvider.decrypt(ciphertext)
|
||||
|
||||
// The decrypted string should be equal to the original plaintext
|
||||
assertEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytesV0() {
|
||||
val encryptionProvider = GEncryptionProviderV0.instance
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
|
||||
// Encrypt the plaintext
|
||||
val ciphertext = encryptionProvider.encrypt(bytes)
|
||||
|
||||
// Decrypt the ciphertext
|
||||
val decrypted = encryptionProvider.decrypt(ciphertext)
|
||||
|
||||
// The decrypted string should be equal to the original plaintext
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
|
||||
}
|
||||
|
||||
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
|
||||
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV1
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GPasswordEncryptionProviderTests {
|
||||
@Test
|
||||
fun testEncryptDecryptBytesPasswordV1() {
|
||||
val encryptionProvider = GPasswordEncryptionProviderV1();
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
|
||||
// Encrypt the plaintext
|
||||
val ciphertext = encryptionProvider.encrypt(bytes, "1234")
|
||||
|
||||
// Decrypt the ciphertext
|
||||
val decrypted = encryptionProvider.decrypt(ciphertext, "1234")
|
||||
|
||||
// The decrypted string should be equal to the original plaintext
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytesPasswordV0() {
|
||||
val encryptionProvider = GPasswordEncryptionProviderV0("1234".padStart(32, '9'));
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
|
||||
// Encrypt the plaintext
|
||||
val ciphertext = encryptionProvider.encrypt(bytes)
|
||||
|
||||
// Decrypt the ciphertext
|
||||
val decrypted = encryptionProvider.decrypt(ciphertext)
|
||||
|
||||
// The decrypted string should be equal to the original plaintext
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
}
|
||||
|
||||
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
|
||||
assertEquals(a.size, b.size);
|
||||
for(i in 0 until a.size) {
|
||||
assertEquals(a[i], b[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -39,6 +41,7 @@
|
||||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
<receiver android:name=".receivers.PlannedNotificationReceiver" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
@@ -92,6 +95,26 @@
|
||||
<data android:host="*" />
|
||||
<data android:scheme="file" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:host="*" />
|
||||
<data android:scheme="content" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:host="*" />
|
||||
<data android:scheme="file" />
|
||||
|
||||
<data android:mimeType="application/zip" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
||||
@@ -540,6 +540,8 @@
|
||||
<script>
|
||||
IS_TESTING = true;
|
||||
let lastScriptTag = null;
|
||||
let shouldDevLog = true;
|
||||
let shouldLoginCheck = true;
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
@@ -603,7 +605,7 @@
|
||||
};
|
||||
setInterval(()=>{
|
||||
try{
|
||||
if(!this.Plugin.currentPlugin)
|
||||
if(!this.Plugin.currentPlugin || !shouldDevLog)
|
||||
return;
|
||||
|
||||
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
|
||||
@@ -638,7 +640,8 @@
|
||||
}, 1000);
|
||||
setInterval(()=>{
|
||||
try{
|
||||
this.isTestLoggedIn();
|
||||
if(shouldLoginCheck)
|
||||
this.isTestLoggedIn();
|
||||
}catch(ex){}
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,8 @@ let Type = {
|
||||
Videos: "VIDEOS",
|
||||
Streams: "STREAMS",
|
||||
Mixed: "MIXED",
|
||||
Live: "LIVE"
|
||||
Live: "LIVE",
|
||||
Subscriptions: "SUBSCRIPTIONS"
|
||||
},
|
||||
Order: {
|
||||
Chronological: "CHRONOLOGICAL"
|
||||
@@ -159,13 +160,27 @@ class FilterCapability {
|
||||
|
||||
|
||||
class PlatformAuthorLink {
|
||||
constructor(id, name, url, thumbnail, subscribers) {
|
||||
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
|
||||
this.id = id ?? PlatformID(); //PlatformID
|
||||
this.name = name ?? ""; //string
|
||||
this.url = url ?? ""; //string
|
||||
this.thumbnail = thumbnail; //string
|
||||
if(subscribers)
|
||||
this.subscribers = subscribers;
|
||||
if(membershipUrl)
|
||||
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
|
||||
}
|
||||
}
|
||||
class PlatformAuthorMembershipLink {
|
||||
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
|
||||
this.id = id ?? PlatformID(); //PlatformID
|
||||
this.name = name ?? ""; //string
|
||||
this.url = url ?? ""; //string
|
||||
this.thumbnail = thumbnail; //string
|
||||
if(subscribers)
|
||||
this.subscribers = subscribers;
|
||||
if(membershipUrl)
|
||||
this.membershipUrl = membershipUrl ?? null; //string
|
||||
}
|
||||
}
|
||||
class PlatformContent {
|
||||
@@ -196,6 +211,16 @@ class PlatformNestedMediaContent extends PlatformContent {
|
||||
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
|
||||
}
|
||||
}
|
||||
class PlatformLockedContent extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 70);
|
||||
obj = obj ?? {};
|
||||
this.contentName = obj.contentName;
|
||||
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
|
||||
this.unlockUrl = obj.unlockUrl ?? "";
|
||||
this.lockDescription = obj.lockDescription;
|
||||
}
|
||||
}
|
||||
class PlatformVideo extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 1);
|
||||
|
||||
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
|
||||
|
||||
return "${value} ${unit}";
|
||||
};
|
||||
fun Int.toHumanTimeIndicator(abs: Boolean = false) : String {
|
||||
var value = this;
|
||||
|
||||
var unit = "s";
|
||||
|
||||
if(abs) value = abs(value);
|
||||
if(value >= secondsInHour) {
|
||||
value = (this / secondsInHour).toInt();
|
||||
if(abs) value = abs(value);
|
||||
unit = "hr" + (if(value > 1) "s" else "");
|
||||
}
|
||||
else if(value >= secondsInMinute) {
|
||||
value = (this / secondsInMinute).toInt();
|
||||
if(abs) value = abs(value);
|
||||
unit = "min";
|
||||
}
|
||||
|
||||
return "${value}${unit}";
|
||||
}
|
||||
|
||||
fun Long.toHumanTime(isMs: Boolean): String {
|
||||
var scaler = 1;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.google.common.base.CharMatcher
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
private const val IPV4_PART_COUNT = 4;
|
||||
@@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
|
||||
return connectedSocket;
|
||||
}
|
||||
|
||||
fun InputStream.readHttpHeaderBytes() : ByteArray {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 4) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
throw IOException("Unexpected end of stream while reading headers")
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
}
|
||||
|
||||
headerBytes.write(b)
|
||||
}
|
||||
|
||||
return headerBytes.toByteArray()
|
||||
}
|
||||
|
||||
fun InputStream.readLine() : String? {
|
||||
val line = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 2) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
line.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
return String(line.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.adapters.CommentViewHolder
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import userpackage.Protocol
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
@@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
|
||||
|
||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||
}
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val exceptions = fullyBackfillServers()
|
||||
for (pair in exceptions) {
|
||||
val server = pair.key
|
||||
val exception = pair.value
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement(
|
||||
"backfill-failed",
|
||||
"Backfill failed",
|
||||
"Failed to backfill server $server. $exception",
|
||||
AnnouncementType.SESSION_RECURRING
|
||||
);
|
||||
|
||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
if(this is T)
|
||||
@@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
||||
if(result != null)
|
||||
return cb(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
fun String?.yesNoToBoolean(): Boolean {
|
||||
return this?.uppercase() == "YES"
|
||||
}
|
||||
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
||||
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
|
||||
else
|
||||
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
|
||||
};
|
||||
Float::class -> {
|
||||
if(this is V8ValueDouble)
|
||||
return this.value.toFloat() as T;
|
||||
else if(this is V8ValueInteger)
|
||||
return this.value.toFloat() as T;
|
||||
else if(this is V8ValueLong)
|
||||
return this.value.toFloat() as T;
|
||||
else
|
||||
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
|
||||
};
|
||||
Double::class -> {
|
||||
if(this is V8ValueDouble)
|
||||
return this.value.toDouble() as T;
|
||||
else if(this is V8ValueInteger)
|
||||
return this.value.toDouble() as T;
|
||||
else if(this is V8ValueLong)
|
||||
return this.value.toDouble() as T;
|
||||
else
|
||||
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
|
||||
};
|
||||
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
|
||||
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
|
||||
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
|
||||
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
|
||||
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
|
||||
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
|
||||
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
|
||||
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
outRect.left = betweenSpace
|
||||
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == 0) {
|
||||
outRect.left = startSpace
|
||||
}
|
||||
|
||||
else if (position == state.itemCount - 1) {
|
||||
outRect.right = endSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,11 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.webkit.CookieManager
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
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.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -30,6 +30,7 @@ import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||
@@ -44,10 +45,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(
|
||||
R.string.manage_polycentric_identity, FieldForm.BUTTON,
|
||||
R.string.manage_your_polycentric_identity, -4
|
||||
)
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -59,10 +57,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.show_faq, FieldForm.BUTTON,
|
||||
R.string.get_answers_to_common_questions, -3
|
||||
)
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
|
||||
@FormFieldButton(R.drawable.ic_quiz)
|
||||
fun openFAQ() {
|
||||
try {
|
||||
@@ -72,10 +67,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
@FormField(
|
||||
R.string.show_issues, FieldForm.BUTTON,
|
||||
R.string.a_list_of_user_reported_and_self_reported_issues, -2
|
||||
)
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
|
||||
@FormFieldButton(R.drawable.ic_data_alert)
|
||||
fun openIssues() {
|
||||
try {
|
||||
@@ -86,6 +78,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@FormField(
|
||||
R.string.submit_feedback, FieldForm.BUTTON,
|
||||
R.string.give_feedback_on_the_application, -1
|
||||
@@ -104,12 +97,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
R.string.manage_tabs, FieldForm.BUTTON,
|
||||
R.string.change_tabs_visible_on_the_home_screen, -1
|
||||
)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
@@ -121,11 +111,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
class LanguageSettings {
|
||||
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
|
||||
@DropdownFieldOptionsId(R.array.app_languages)
|
||||
var appLanguage: Int = 0;
|
||||
|
||||
fun getAppLanguageLocaleString(): String? {
|
||||
return when(appLanguage) {
|
||||
0 -> null
|
||||
1 -> "en";
|
||||
2 -> "de";
|
||||
3 -> "es";
|
||||
4 -> "pt";
|
||||
5 -> "fr"
|
||||
6 -> "ja";
|
||||
7 -> "ko";
|
||||
8 -> "zh";
|
||||
9 -> "ru";
|
||||
10 -> "ar";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
|
||||
var home = HomeSettings();
|
||||
@Serializable
|
||||
class HomeSettings {
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
@@ -135,21 +153,45 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = false;
|
||||
|
||||
|
||||
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
|
||||
@FormFieldButton(R.drawable.ic_visibility_off)
|
||||
fun clearHidden() {
|
||||
StateMeta.instance.removeAllHiddenCreators();
|
||||
StateMeta.instance.removeAllHiddenVideos();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.search, "group", -1, 2)
|
||||
var search = SearchSettings();
|
||||
@Serializable
|
||||
class SearchSettings {
|
||||
@FormField(R.string.search_history, FieldForm.TOGGLE, -1, 4)
|
||||
@FormField(R.string.search_history, FieldForm.TOGGLE, R.string.may_require_restart, 3)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var searchHistory: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var searchFeedStyle: Int = 1;
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = false;
|
||||
|
||||
|
||||
fun getSearchFeedStyle(): FeedStyle {
|
||||
if(searchFeedStyle == 0)
|
||||
@@ -159,11 +201,21 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
|
||||
|
||||
@FormField(R.string.channel, "group", -1, 3)
|
||||
var channel = ChannelSettings();
|
||||
@Serializable
|
||||
class ChannelSettings {
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
|
||||
var subscriptions = SubscriptionsSettings();
|
||||
@Serializable
|
||||
class SubscriptionsSettings {
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 4)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var subscriptionsFeedStyle: Int = 1;
|
||||
|
||||
@@ -174,11 +226,20 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = false;
|
||||
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var fetchOnAppBoot: Boolean = true;
|
||||
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
|
||||
@DropdownFieldOptionsId(R.array.background_interval)
|
||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||
|
||||
@@ -194,24 +255,41 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
|
||||
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8)
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var subscriptionConcurrency: Int = 3;
|
||||
|
||||
fun getSubscriptionsConcurrency() : Int {
|
||||
return threadIndexToCount(subscriptionConcurrency);
|
||||
}
|
||||
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
ChannelContentCache.instance.clear();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
|
||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
|
||||
var playback = PlaybackSettings();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.languages)
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage];
|
||||
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
|
||||
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||
@@ -270,10 +348,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.resume_after_preview)
|
||||
var resumeAfterPreview: Int = 1;
|
||||
|
||||
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
fun shouldResumePreview(previewedPosition: Long): Boolean{
|
||||
if(resumeAfterPreview == 2)
|
||||
return true;
|
||||
@@ -281,9 +355,40 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
|
||||
@DropdownFieldOptionsId(R.array.chapter_fps)
|
||||
var chapterUpdateFPS: Int = 0;
|
||||
|
||||
fun getChapterUpdateFrames(): Int {
|
||||
return when(chapterUpdateFPS) {
|
||||
0 -> 24
|
||||
1 -> 30
|
||||
2 -> 60
|
||||
3 -> 120
|
||||
else -> 1
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
||||
var backgroundSwitchToAudio: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
var comments = CommentSettings();
|
||||
@Serializable
|
||||
class CommentSettings {
|
||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||
var defaultCommentSection: Int = 0;
|
||||
}
|
||||
|
||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||
var downloads = Downloads();
|
||||
@Serializable
|
||||
class Downloads {
|
||||
@@ -323,7 +428,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6)
|
||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
|
||||
var browsing = Browsing();
|
||||
@Serializable
|
||||
class Browsing {
|
||||
@@ -332,7 +437,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var videoCache: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.casting, "group", R.string.configure_casting, 7)
|
||||
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||
var casting = Casting();
|
||||
@Serializable
|
||||
class Casting {
|
||||
@@ -340,6 +445,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var enabled: Boolean = true;
|
||||
|
||||
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var keepScreenOn: Boolean = true;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@@ -357,8 +465,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}*/
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
|
||||
var logging = Logging();
|
||||
@Serializable
|
||||
class Logging {
|
||||
@@ -366,10 +473,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
@FormField(
|
||||
R.string.submit_logs, FieldForm.BUTTON,
|
||||
R.string.submit_logs_to_help_us_narrow_down_issues, 1
|
||||
)
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -385,23 +489,26 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
|
||||
var announcementSettings = AnnouncementSettings();
|
||||
@Serializable
|
||||
class AnnouncementSettings {
|
||||
@FormField(
|
||||
R.string.reset_announcements, FieldForm.BUTTON,
|
||||
R.string.reset_hidden_announcements, 1
|
||||
)
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun resetAnnouncements() {
|
||||
StateAnnouncement.instance.resetAnnouncements();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 11)
|
||||
@FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
|
||||
var notifications = NotificationSettings();
|
||||
@Serializable
|
||||
class NotificationSettings {
|
||||
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
|
||||
var plannedContentNotification: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||
@Transient
|
||||
var plugins = Plugins();
|
||||
@Serializable
|
||||
@@ -410,18 +517,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
@FormField(
|
||||
R.string.clear_cookies, FieldForm.BUTTON,
|
||||
R.string.clears_in_app_browser_cookies, 1
|
||||
)
|
||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(
|
||||
R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
|
||||
R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
|
||||
)
|
||||
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||
fun reinstallEmbedded() {
|
||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -444,7 +545,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
|
||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
|
||||
var storage = Storage();
|
||||
@Serializable
|
||||
class Storage {
|
||||
@@ -468,10 +569,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
|
||||
fun clearStorageDownload() {
|
||||
Settings.instance.storage.storage_download = null;
|
||||
Settings.instance.save();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 12)
|
||||
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
|
||||
var autoUpdate = AutoUpdate();
|
||||
@Serializable
|
||||
class AutoUpdate {
|
||||
@@ -500,10 +608,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.manual_check, FieldForm.BUTTON,
|
||||
R.string.manually_check_for_updates, 3
|
||||
)
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -520,10 +625,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.view_changelog, FieldForm.BUTTON,
|
||||
R.string.review_the_current_and_past_changelogs, 4
|
||||
)
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
@@ -543,10 +645,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.remove_cached_version, FieldForm.BUTTON,
|
||||
R.string.remove_the_last_downloaded_version, 5
|
||||
)
|
||||
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
|
||||
fun removeCachedVersion() {
|
||||
StateApp.withContext {
|
||||
val outputDirectory = File(it.filesDir, "autoupdate");
|
||||
@@ -562,7 +661,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 13)
|
||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 16)
|
||||
var backup = Backup();
|
||||
@Serializable
|
||||
class Backup {
|
||||
@@ -596,9 +695,26 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun export() {
|
||||
StateBackup.startExternalBackup();
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
StateApp.instance.requestFileReadAccess(act, null) {
|
||||
if(it != null && it.exists()) {
|
||||
val name = it.name;
|
||||
val contents = it.readBytes(act);
|
||||
if(contents != null) {
|
||||
if(name != null && name.endsWith(".zip", true))
|
||||
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 14)
|
||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 17)
|
||||
var payment = Payment();
|
||||
@Serializable
|
||||
class Payment {
|
||||
@@ -615,7 +731,16 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 15)
|
||||
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
|
||||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
||||
var bypassRotationPrevention: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
||||
@@ -2,14 +2,25 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
@@ -28,6 +39,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@@ -87,11 +100,31 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance()
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 3)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||
.build();
|
||||
wm.enqueue(req);
|
||||
}
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 3)
|
||||
fun clearChannelContentCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||
ChannelContentCache.instance.clearToday();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||
}
|
||||
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField(R.string.v8_benchmarks, FieldForm.GROUP,
|
||||
R.string.various_benchmarks_using_the_integrated_v8_engine, 3)
|
||||
R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
|
||||
val v8Benchmarks: V8Benchmarks = V8Benchmarks();
|
||||
class V8Benchmarks {
|
||||
@FormField(
|
||||
@@ -139,7 +172,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(
|
||||
R.string.test_v8_communication_speed, FieldForm.BUTTON,
|
||||
R.string.tests_v8_communication_speeds, 2
|
||||
R.string.tests_v8_communication_speeds, 4
|
||||
)
|
||||
fun testV8RunSpeeds() {
|
||||
var plugin: V8Plugin? = null;
|
||||
|
||||
@@ -5,9 +5,12 @@ 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.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
@@ -52,7 +55,6 @@ class UISlideOverlays {
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
val originalLive = subscription.doFetchLive;
|
||||
@@ -60,52 +62,69 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
|
||||
}, false)));
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
menu.onOK.subscribe {
|
||||
StateSubscriptions.instance.saveSubscription(subscription);
|
||||
menu.hide(true);
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
}, false) else null).filterNotNull());
|
||||
|
||||
menu.show();
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||
@@ -367,6 +386,33 @@ class UISlideOverlays {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
|
||||
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
addPlaylistOverlay.onOK.subscribe {
|
||||
val text = nameInput.text;
|
||||
if (text.isBlank()) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
addPlaylistOverlay.hide();
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
onCreate(text)
|
||||
};
|
||||
|
||||
addPlaylistOverlay.onCancel.subscribe {
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
};
|
||||
|
||||
addPlaylistOverlay.show();
|
||||
nameInput.activate();
|
||||
|
||||
return addPlaylistOverlay
|
||||
}
|
||||
|
||||
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
|
||||
@@ -387,8 +433,13 @@ class UISlideOverlays {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false))
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
}))
|
||||
+ actions)
|
||||
));
|
||||
items.add(
|
||||
@@ -400,6 +451,13 @@ class UISlideOverlays {
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
};
|
||||
}, false))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
|
||||
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
|
||||
}
|
||||
}
|
||||
|
||||
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
|
||||
fun String.isHttpUrl(): Boolean {
|
||||
return _regexUrl.matchEntire(this) != null;
|
||||
}
|
||||
|
||||
|
||||
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
|
||||
fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
@@ -158,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
|
||||
}
|
||||
|
||||
fun File.share(context: Context) {
|
||||
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
|
||||
|
||||
fun DocumentFile.share(context: Context) {
|
||||
val shareIntent = Intent();
|
||||
shareIntent.action = Intent.ACTION_SEND;
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
private var _config: SourcePluginConfig? = null;
|
||||
private var _script: String? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,6 +8,7 @@ import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
@@ -43,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_add_source_options);
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.CaptchaWebViewClient
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_captcha);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_exception);
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
@@ -15,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -23,13 +26,25 @@ import kotlinx.serialization.json.Json
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _textUrl: TextView;
|
||||
private lateinit var _buttonClose: ImageButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
finish();
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
@@ -60,6 +75,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
import android.widget.FrameLayout
|
||||
@@ -154,6 +156,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
Logger.i(TAG, "MainActivity.attachBaseContext")
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
@@ -321,6 +328,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fragCurrent.onOrientationChanged(it);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_fragVideoDetail.onOrientationChanged(it);
|
||||
else if(Settings.instance.other.bypassRotationPrevention)
|
||||
{
|
||||
requestedOrientation = when(orientation) {
|
||||
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
}
|
||||
}
|
||||
};
|
||||
_orientationManager.enable();
|
||||
|
||||
@@ -497,6 +513,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
};
|
||||
startActivity(intent);
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://video/")) {
|
||||
val videoUrl = targetData.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = targetData.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
"content" -> {
|
||||
if(!handleContent(targetData, intent.type)) {
|
||||
@@ -583,6 +607,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, data);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleFile(file: String): Boolean {
|
||||
@@ -600,6 +627,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt")) {
|
||||
return handleUnknownText(String(readSharedFile(file)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleReconstruction(recon: String) {
|
||||
@@ -625,6 +655,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUnknownText(text: String): Boolean {
|
||||
try {
|
||||
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
|
||||
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
|
||||
navigate(_fragImportSubscriptions, lines);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, ex.message, ex);
|
||||
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleUnknownJson(name: String?, json: String): Boolean {
|
||||
|
||||
val context = this;
|
||||
@@ -745,6 +789,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateSaved.instance.setVideoToOpenBlocking(null);
|
||||
}
|
||||
|
||||
inline fun <reified T> isFragmentActive(): Boolean {
|
||||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
@@ -838,15 +885,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
||||
navigate(fragBeforeOverlay!!, null, false, true);
|
||||
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
val last = _queue.lastOrNull();
|
||||
if (last != null) {
|
||||
_queue.remove(last);
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else
|
||||
finish();
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
finish();
|
||||
} else {
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.ItemMoveCallback
|
||||
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
|
||||
private lateinit var _recyclerTabs: RecyclerView;
|
||||
private lateinit var _touchHelper: ItemTouchHelper;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_manage_tabs);
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.*
|
||||
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_backup);
|
||||
|
||||
+8
-1
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -9,8 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
|
||||
private var _creating = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_create_profile);
|
||||
@@ -76,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonImportProfile: BigButton;
|
||||
private lateinit var _layoutButtons: LinearLayout;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_home);
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.*
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -18,6 +19,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dialogs.CommentDialog
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
@@ -29,6 +31,7 @@ import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.toURLInfoDataLink
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -47,6 +50,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _imagePolycentric: ImageView;
|
||||
private var _avatarUri: Uri? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_profile);
|
||||
@@ -188,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
if (hasChanges) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
||||
@@ -222,7 +229,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
|
||||
|
||||
Glide.with(_imagePolycentric)
|
||||
.load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList()))
|
||||
.load(avatar?.toURLInfoSystemLinkUrl(processHandle.system.toProto(), avatar.process, systemState.servers.toList()))
|
||||
.placeholder(R.drawable.placeholder_profile)
|
||||
.crossfade()
|
||||
.into(_imagePolycentric)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,12 +8,17 @@ import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class PolycentricWhyActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonVideo: BigButton;
|
||||
private lateinit var _buttonTechnical: BigButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_why);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -13,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
@@ -28,6 +30,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
|
||||
private var _isFinished = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
@@ -43,6 +49,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
|
||||
if(field.descriptor?.id == "app_language") {
|
||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
||||
}
|
||||
};
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
|
||||
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedReader
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.StringWriter
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class HttpContext : AutoCloseable {
|
||||
private val _stream: BufferedReader;
|
||||
private val _inputStream: InputStream;
|
||||
private var _responseStream: OutputStream? = null;
|
||||
|
||||
|
||||
var id: String? = null;
|
||||
|
||||
|
||||
var head: String = "";
|
||||
var headers: HttpHeaders = HttpHeaders();
|
||||
|
||||
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
|
||||
private val _responseHeaders: HttpHeaders = HttpHeaders();
|
||||
|
||||
|
||||
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
|
||||
_stream = stream;
|
||||
constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
|
||||
_inputStream = inputStream;
|
||||
_responseStream = responseStream;
|
||||
this.id = requestId;
|
||||
|
||||
try {
|
||||
head = stream.readLine() ?: throw EmptyRequestException("No head found");
|
||||
}
|
||||
catch(ex: SocketTimeoutException) {
|
||||
if((timeout ?: 0) > 0)
|
||||
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
val methodEndIndex = head.indexOf(' ');
|
||||
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
|
||||
if (methodEndIndex == -1 || urlEndIndex == -1) {
|
||||
Logger.w(TAG, "Skipped request, wrong format.");
|
||||
throw IllegalStateException("Invalid request");
|
||||
}
|
||||
|
||||
method = head.substring(0, methodEndIndex);
|
||||
path = head.substring(methodEndIndex + 1, urlEndIndex);
|
||||
|
||||
if (path.contains("?")) {
|
||||
val queryPartIndex = path.indexOf("?");
|
||||
val queryParts = path.substring(queryPartIndex + 1).split("&");
|
||||
path = path.substring(0, queryPartIndex);
|
||||
|
||||
for(queryPart in queryParts) {
|
||||
val eqIndex = queryPart.indexOf("=");
|
||||
if(eqIndex > 0)
|
||||
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
|
||||
else
|
||||
query.put(queryPart, "");
|
||||
val headerBytes = readHeaderBytes()
|
||||
ByteArrayInputStream(headerBytes).use {
|
||||
val reader = it.bufferedReader(Charsets.UTF_8)
|
||||
try {
|
||||
head = reader.readLine() ?: throw EmptyRequestException("No head found");
|
||||
}
|
||||
catch(ex: SocketTimeoutException) {
|
||||
if((timeout ?: 0) > 0)
|
||||
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val line = stream.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
val methodEndIndex = head.indexOf(' ');
|
||||
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
|
||||
if (methodEndIndex == -1 || urlEndIndex == -1) {
|
||||
Logger.w(TAG, "Skipped request, wrong format.");
|
||||
throw IllegalStateException("Invalid request");
|
||||
}
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
method = head.substring(0, methodEndIndex);
|
||||
path = head.substring(methodEndIndex + 1, urlEndIndex);
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
|
||||
"keep-alive" -> {
|
||||
val keepAliveParams = headerValue.split(",");
|
||||
for(keepAliveParam in keepAliveParams) {
|
||||
val eqIndex = keepAliveParam.indexOf("=");
|
||||
if(eqIndex > 0){
|
||||
when(keepAliveParam.substring(0, eqIndex)) {
|
||||
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
if (path.contains("?")) {
|
||||
val queryPartIndex = path.indexOf("?");
|
||||
val queryParts = path.substring(queryPartIndex + 1).split("&");
|
||||
path = path.substring(0, queryPartIndex);
|
||||
|
||||
for(queryPart in queryParts) {
|
||||
val eqIndex = queryPart.indexOf("=");
|
||||
if(eqIndex > 0)
|
||||
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
|
||||
else
|
||||
query.put(queryPart, "");
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val line = reader.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
|
||||
"keep-alive" -> {
|
||||
val keepAliveParams = headerValue.split(",");
|
||||
for(keepAliveParam in keepAliveParams) {
|
||||
val eqIndex = keepAliveParam.indexOf("=");
|
||||
if(eqIndex > 0){
|
||||
when(keepAliveParam.substring(0, eqIndex)) {
|
||||
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHeaderBytes(): ByteArray {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 4) {
|
||||
val b = _inputStream.read()
|
||||
if (b == -1) {
|
||||
throw IOException("Unexpected end of stream while reading headers")
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
}
|
||||
|
||||
headerBytes.write(b)
|
||||
}
|
||||
|
||||
return headerBytes.toByteArray()
|
||||
}
|
||||
|
||||
fun readContentBytes(buffer: ByteArray, length: Int): Int {
|
||||
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
|
||||
val read = _inputStream.read(buffer, 0, remainingBytes);
|
||||
if (read > 0) {
|
||||
_totalRead += read
|
||||
}
|
||||
|
||||
return read;
|
||||
}
|
||||
fun readContentString(): String {
|
||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
var read: Int
|
||||
while (true) {
|
||||
read = readContentBytes(buffer, buffer.size)
|
||||
if (read <= 0) break
|
||||
byteArrayOutputStream.write(buffer, 0, read)
|
||||
}
|
||||
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
return Serializer.json.decodeFromString(readContentString());
|
||||
}
|
||||
fun skipBody() {
|
||||
if (contentLength > 0)
|
||||
_inputStream.skip(contentLength - _totalRead)
|
||||
}
|
||||
|
||||
fun getHttpHeaderString(): String {
|
||||
val writer = StringWriter();
|
||||
writer.write(head + "\r\n");
|
||||
@@ -139,8 +197,13 @@ class HttpContext : AutoCloseable {
|
||||
}
|
||||
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
||||
val bytes = body?.toByteArray(Charsets.UTF_8);
|
||||
if(body != null && headers.get("content-length").isNullOrEmpty())
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
if(headers.get("content-length").isNullOrEmpty()) {
|
||||
if (body != null) {
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
} else {
|
||||
headers.put("content-length", "0")
|
||||
}
|
||||
}
|
||||
respond(status, headers) { responseStream ->
|
||||
if(body != null) {
|
||||
responseStream.write(bytes!!);
|
||||
@@ -161,8 +224,7 @@ class HttpContext : AutoCloseable {
|
||||
headersToRespond.put("keep-alive", "timeout=5, max=1000");
|
||||
}
|
||||
|
||||
val responseHeader = HttpResponse(status, headers);
|
||||
|
||||
val responseHeader = HttpResponse(status, headersToRespond);
|
||||
responseStream.write(responseHeader.getHttpHeaderBytes());
|
||||
|
||||
if(method != "HEAD") {
|
||||
@@ -172,38 +234,9 @@ class HttpContext : AutoCloseable {
|
||||
statusCode = status;
|
||||
}
|
||||
|
||||
fun readContentBytes(buffer: CharArray, length: Int) : Int {
|
||||
val reading = Math.min(length, (contentLength - _totalRead).toInt());
|
||||
val read = _stream.read(buffer, 0, reading);
|
||||
_totalRead += read;
|
||||
|
||||
//TODO: Fix this properly
|
||||
if(contentLength - _totalRead < 400 && read < length) {
|
||||
_totalRead = contentLength;
|
||||
}
|
||||
return read;
|
||||
}
|
||||
fun readContentString() : String{
|
||||
val writer = StringWriter();
|
||||
var read = 0;
|
||||
val buffer = CharArray(4096);
|
||||
do {
|
||||
read = readContentBytes(buffer, buffer.size);
|
||||
writer.write(buffer, 0, read);
|
||||
} while(read > 0);
|
||||
return writer.toString();
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
return Serializer.json.decodeFromString(readContentString());
|
||||
}
|
||||
fun skipBody() {
|
||||
if(contentLength > 0)
|
||||
_stream.skip(contentLength - _totalRead);
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if(!keepAlive) {
|
||||
_stream?.close();
|
||||
_inputStream.close();
|
||||
_responseStream?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,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 java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
@@ -18,6 +17,7 @@ import java.util.*
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
private val _client : ManagedHttpClient = ManagedHttpClient();
|
||||
@@ -29,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
var port = 0
|
||||
private set;
|
||||
|
||||
private val _handlers = mutableListOf<HttpHandler>();
|
||||
private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
|
||||
private val _headHandlers = hashMapOf<String, HttpHandler>()
|
||||
private var _workerPool: ExecutorService? = null;
|
||||
|
||||
@Synchronized
|
||||
@@ -76,12 +77,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
|
||||
private fun handleClientRequest(socket: Socket) {
|
||||
_workerPool?.submit {
|
||||
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
val requestStream = BufferedInputStream(socket.getInputStream());
|
||||
val responseStream = socket.getOutputStream();
|
||||
|
||||
val requestId = UUID.randomUUID().toString().substring(0, 5);
|
||||
try {
|
||||
keepAliveLoop(requestReader, responseStream, requestId) { req ->
|
||||
keepAliveLoop(requestStream, responseStream, requestId) { req ->
|
||||
req.use { httpContext ->
|
||||
if(!httpContext.path.startsWith("/plugin/"))
|
||||
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
|
||||
@@ -107,7 +108,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
Logger.e(TAG, "Failed to handle client request.", e);
|
||||
}
|
||||
finally {
|
||||
requestReader.close();
|
||||
requestStream.close();
|
||||
responseStream.close();
|
||||
}
|
||||
};
|
||||
@@ -115,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
|
||||
fun getHandler(method: String, path: String) : HttpHandler? {
|
||||
synchronized(_handlers) {
|
||||
//TODO: Support regex paths?
|
||||
if(method == "HEAD")
|
||||
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") }
|
||||
return _handlers.firstOrNull { it.method == method && it.path == path };
|
||||
if (method == "HEAD") {
|
||||
return _headHandlers[path]
|
||||
}
|
||||
|
||||
val handlerMap = _handlers[method] ?: return null
|
||||
return handlerMap[path]
|
||||
}
|
||||
}
|
||||
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||
synchronized(_handlers) {
|
||||
_handlers.add(handler);
|
||||
handler.allowHEAD = withHEAD;
|
||||
|
||||
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
|
||||
if (handlerMap == null) {
|
||||
handlerMap = hashMapOf()
|
||||
_handlers[handler.method] = handlerMap
|
||||
}
|
||||
|
||||
handlerMap[handler.path] = handler;
|
||||
if (handler.allowHEAD || handler.method == "HEAD") {
|
||||
_headHandlers[handler.path] = handler
|
||||
}
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
fun removeHandler(method: String, path: String) {
|
||||
synchronized(_handlers) {
|
||||
val handler = getHandler(method, path);
|
||||
if(handler != null)
|
||||
_handlers.remove(handler);
|
||||
val handlerMap = _handlers[method] ?: return
|
||||
val handler = handlerMap.remove(path) ?: return
|
||||
if (method == "HEAD" || handler.allowHEAD) {
|
||||
_headHandlers.remove(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun removeAllHandlers(tag: String? = null) {
|
||||
synchronized(_handlers) {
|
||||
if(tag == null)
|
||||
_handlers.clear();
|
||||
else
|
||||
_handlers.removeIf { it.tag == tag };
|
||||
else {
|
||||
for (pair in _handlers) {
|
||||
val toRemove = ArrayList<String>()
|
||||
for (innerPair in pair.value) {
|
||||
if (innerPair.value.tag == tag) {
|
||||
toRemove.add(innerPair.key)
|
||||
|
||||
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
|
||||
_headHandlers.remove(innerPair.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (x in toRemove)
|
||||
pair.value.remove(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun addBridgeHandlers(obj: Any, tag: String? = null) {
|
||||
@@ -188,7 +218,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
|
||||
private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
|
||||
val stopCount = _stopCount;
|
||||
var keepAlive = false;
|
||||
var requestsMax = 0;
|
||||
|
||||
-1
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
|
||||
val headers = this.headers.clone();
|
||||
if(contentType != null)
|
||||
headers["Content-Type"] = contentType;
|
||||
headers["Content-Length"] = content.length.toString();
|
||||
|
||||
httpContext.respondCode(200, headers, content);
|
||||
}
|
||||
|
||||
+13
-22
@@ -1,14 +1,16 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) {
|
||||
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
val requestHeaders = httpContext.headers;
|
||||
val responseHeaders = this.headers.clone();
|
||||
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
|
||||
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
|
||||
|
||||
val acceptEncoding = requestHeaders["Accept-Encoding"]
|
||||
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
|
||||
if (shouldGzip) {
|
||||
responseHeaders["Content-Encoding"] = "gzip"
|
||||
}
|
||||
|
||||
val range = requestHeaders["Range"]
|
||||
var start: Long
|
||||
val start: Long
|
||||
val end: Long
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
val parts = range.substring(6).split("-")
|
||||
start = parts[0].toLong()
|
||||
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1)
|
||||
end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
|
||||
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
|
||||
} else {
|
||||
start = 0
|
||||
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
|
||||
var totalBytesSent = 0
|
||||
val contentLength = end - start + 1
|
||||
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
|
||||
responseHeaders["Content-Length"] = contentLength.toString()
|
||||
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
|
||||
|
||||
file.inputStream().use { inputStream ->
|
||||
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream ->
|
||||
httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
|
||||
try {
|
||||
val buffer = ByteArray(8192)
|
||||
inputStream.skip(start)
|
||||
var current = start
|
||||
|
||||
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream
|
||||
val outputStream = responseStream
|
||||
while (true) {
|
||||
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong());
|
||||
val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
|
||||
val bytesRead = inputStream.read(buffer);
|
||||
if (bytesRead < 0) {
|
||||
Logger.i(TAG, "End of file reached")
|
||||
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
||||
outputStream.write(buffer, 0, bytesToSend)
|
||||
|
||||
totalBytesSent += bytesToSend
|
||||
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||
|
||||
start += bytesToSend.toLong()
|
||||
if (start >= end) {
|
||||
current += bytesToSend.toLong()
|
||||
if (current >= end) {
|
||||
Logger.i(TAG, "Expected amount of bytes sent")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Finished sending file (segment)")
|
||||
|
||||
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
|
||||
outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
httpContext.respondCode(500, headers)
|
||||
}
|
||||
}
|
||||
|
||||
if (closeAfterRequest) {
|
||||
httpContext.keepAlive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
|
||||
headers.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
|
||||
|
||||
fun withTag(tag: String) : HttpHandler {
|
||||
|
||||
+4
-11
@@ -4,17 +4,10 @@ import com.futo.platformplayer.api.http.server.HttpContext
|
||||
|
||||
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
//Just allow whatever is requested
|
||||
|
||||
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", "");
|
||||
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
|
||||
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
|
||||
|
||||
val newHeaders = headers.clone();
|
||||
newHeaders.put("Allow", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*");
|
||||
|
||||
val newHeaders = headers.clone()
|
||||
newHeaders.put("Access-Control-Allow-Origin", "*")
|
||||
newHeaders.put("Access-Control-Allow-Methods", "*")
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*")
|
||||
httpContext.respondCode(200, newHeaders);
|
||||
}
|
||||
}
|
||||
+160
-6
@@ -1,11 +1,20 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.Exception
|
||||
import java.net.Socket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) {
|
||||
class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
|
||||
var content: String? = null;
|
||||
var contentType: String? = null;
|
||||
|
||||
@@ -17,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
private var _injectHost = false;
|
||||
private var _injectReferer = false;
|
||||
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
handleWithTcp(context)
|
||||
} else {
|
||||
handleWithOkHttp(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWithOkHttp(context: HttpContext) {
|
||||
val proxyHeaders = HashMap<String, String>();
|
||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||
proxyHeaders[header.key] = header.value;
|
||||
@@ -34,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
//Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}");
|
||||
//Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
};
|
||||
|
||||
//Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) });
|
||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||
for(newHeader in headers)
|
||||
headersFiltered.put(newHeader.key, newHeader.value);
|
||||
|
||||
@@ -65,6 +81,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWithTcp(context: HttpContext) {
|
||||
if (content != null)
|
||||
throw NotImplementedError("Content body is not supported")
|
||||
|
||||
val proxyHeaders = HashMap<String, String>();
|
||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||
proxyHeaders[header.key] = header.value;
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
|
||||
}
|
||||
|
||||
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
|
||||
val requestBuilder = StringBuilder()
|
||||
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
|
||||
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
|
||||
requestBuilder.append("\r\n")
|
||||
|
||||
val port = if (parsed.port == -1) {
|
||||
when (parsed.scheme) {
|
||||
"https" -> 443
|
||||
"http" -> 80
|
||||
else -> throw Exception("Unhandled scheme")
|
||||
}
|
||||
} else {
|
||||
parsed.port
|
||||
}
|
||||
|
||||
val socket = if (parsed.scheme == "https") {
|
||||
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
sslSocketFactory.createSocket(parsed.host, port)
|
||||
} else {
|
||||
Socket(parsed.host, port)
|
||||
}
|
||||
|
||||
socket.use { s ->
|
||||
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
|
||||
|
||||
val inputStream = s.getInputStream()
|
||||
val resp = HttpResponseParser(inputStream)
|
||||
if (resp.statusCode == 302) {
|
||||
val location = resp.location!!
|
||||
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
|
||||
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
|
||||
} else {
|
||||
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
|
||||
val contentLength = resp.contentLength.toInt()
|
||||
|
||||
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||
for (newHeader in headers)
|
||||
headersFiltered.put(newHeader.key, newHeader.value);
|
||||
|
||||
context.respond(resp.statusCode, headersFiltered) { responseStream ->
|
||||
if (isChunked) {
|
||||
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
|
||||
handleChunkedTransfer(inputStream, responseStream)
|
||||
} else if (contentLength > 0) {
|
||||
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
|
||||
transferFixedLengthContent(inputStream, responseStream, contentLength)
|
||||
} else if (contentLength == -1) {
|
||||
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
|
||||
transferUntilEndOfStream(inputStream, responseStream)
|
||||
} else {
|
||||
Logger.i(TAG, "handleWithTcp no content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
|
||||
var line: String?
|
||||
val buffer = ByteArray(8192)
|
||||
|
||||
while (inputStream.readLine().also { line = it } != null) {
|
||||
val size = line!!.trim().toInt(16)
|
||||
|
||||
responseStream.write(line!!.encodeToByteArray())
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
|
||||
if (size == 0) {
|
||||
inputStream.skip(2)
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
break
|
||||
}
|
||||
|
||||
var totalRead = 0
|
||||
while (totalRead < size) {
|
||||
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
|
||||
if (read == -1) break
|
||||
responseStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
}
|
||||
|
||||
inputStream.skip(2)
|
||||
responseStream.write("\r\n".encodeToByteArray())
|
||||
responseStream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
|
||||
val buffer = ByteArray(8192)
|
||||
var totalRead = 0
|
||||
while (totalRead < contentLength) {
|
||||
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
|
||||
if (read == -1) break
|
||||
responseStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
}
|
||||
|
||||
responseStream.flush()
|
||||
}
|
||||
|
||||
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } >= 0) {
|
||||
responseStream.write(buffer, 0, read)
|
||||
}
|
||||
|
||||
responseStream.flush()
|
||||
}
|
||||
|
||||
fun withContent(body: String) : HttpProxyHandler {
|
||||
this.content = body;
|
||||
return this;
|
||||
@@ -92,4 +242,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
||||
_ignoreRequestHeaders.add("referer");
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
* A link to a channel, often with its own name and thumbnail
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
class PlatformAuthorLink {
|
||||
open class PlatformAuthorLink {
|
||||
val id: PlatformID;
|
||||
val name: String;
|
||||
val url: String;
|
||||
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||
if(value.has("membershipUrl"))
|
||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||
|
||||
val context = "AuthorLink"
|
||||
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||
value.getOrThrow(config ,"name", context),
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
* A link to a channel, often with its own name and thumbnail
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
class PlatformAuthorMembershipLink: PlatformAuthorLink {
|
||||
val membershipUrl: String?;
|
||||
|
||||
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null, membershipUrl: String? = null): super(id, name, url, thumbnail, subscribers)
|
||||
{
|
||||
this.membershipUrl = membershipUrl;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
|
||||
val context = "AuthorMembershipLink"
|
||||
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||
value.getOrThrow(config ,"name", context),
|
||||
value.getOrThrow(config, "url", context),
|
||||
value.getOrDefault<String>(config, "thumbnail", context, null),
|
||||
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null,
|
||||
if(value.has("membershipUrl")) value.getOrThrow(config, "membershipUrl", context) else null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class ResultCapabilities(
|
||||
const val TYPE_LIVE = "LIVE";
|
||||
const val TYPE_POSTS = "POSTS";
|
||||
const val TYPE_MIXED = "MIXED";
|
||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||
|
||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ class Thumbnails {
|
||||
fun getLQThumbnail() : String? {
|
||||
return sources.firstOrNull()?.url;
|
||||
}
|
||||
fun getMinimumThumbnail(quality: Int): String? {
|
||||
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
|
||||
}
|
||||
|
||||
fun hasMultiple() = sources.size > 1;
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
interface IChapter {
|
||||
val name: String;
|
||||
val type: ChapterType;
|
||||
val timeStart: Int;
|
||||
val timeEnd: Int;
|
||||
val timeStart: Double;
|
||||
val timeEnd: Double;
|
||||
}
|
||||
|
||||
enum class ChapterType(val value: Int) {
|
||||
|
||||
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
|
||||
|
||||
NESTED_VIDEO(11),
|
||||
|
||||
LOCKED(70),
|
||||
|
||||
PLACEHOLDER(90),
|
||||
DEFERRED(91);
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.futo.platformplayer.api.media.models.locked
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
||||
interface IPlatformLockedContent: IPlatformContent {
|
||||
val lockContentType: ContentType;
|
||||
val lockDescription: String?;
|
||||
val unlockUrl: String?;
|
||||
val contentName: String?;
|
||||
val contentThumbnails: Thumbnails;
|
||||
}
|
||||
+2
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
@@ -18,6 +19,7 @@ interface SerializedPlatformContent: IPlatformContent {
|
||||
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
|
||||
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
|
||||
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
|
||||
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
|
||||
else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
|
||||
};
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class SerializedPlatformLockedContent(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?,
|
||||
override val url: String,
|
||||
override val shareUrl: String,
|
||||
override val lockContentType: ContentType,
|
||||
override val contentName: String?,
|
||||
override val lockDescription: String? = null,
|
||||
override val unlockUrl: String? = null,
|
||||
override val contentThumbnails: Thumbnails
|
||||
) : IPlatformLockedContent, SerializedPlatformContent {
|
||||
final override val contentType: ContentType get() = ContentType.LOCKED;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
override fun fromJson(str : String) : SerializedPlatformLockedContent {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
|
||||
}
|
||||
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
|
||||
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
|
||||
return SerializedPlatformLockedContent(
|
||||
content.id,
|
||||
content.name,
|
||||
content.author,
|
||||
content.datetime,
|
||||
content.url,
|
||||
content.shareUrl,
|
||||
content.lockContentType,
|
||||
content.contentName,
|
||||
content.lockDescription,
|
||||
content.unlockUrl,
|
||||
content.contentThumbnails
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,19 @@ open class JSClient : IPlatformClient {
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
|
||||
if(settingsRateLimit > 0) {
|
||||
if(pluginRateLimit != null)
|
||||
return settingsRateLimit.coerceAtMost(pluginRateLimit);
|
||||
else
|
||||
return settingsRateLimit;
|
||||
}
|
||||
else
|
||||
return pluginRateLimit;
|
||||
}
|
||||
|
||||
val onDisabled = Event1<JSClient>();
|
||||
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
|
||||
|
||||
@@ -571,7 +584,7 @@ open class JSClient : IPlatformClient {
|
||||
if(it.containsKey(claimType)) {
|
||||
val templates = it[claimType];
|
||||
if(templates != null)
|
||||
for(value in values.keys.sortedBy { it }) {
|
||||
for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
|
||||
if(templates.containsKey(value)) {
|
||||
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
||||
override fun toString(): String {
|
||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
return EncryptionProvider.instance.encrypt(serialize());
|
||||
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
|
||||
val TAG = "SourceAuth";
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
||||
if(encrypted == null)
|
||||
return null;
|
||||
|
||||
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
|
||||
try {
|
||||
return deserialize(decrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize authentication", ex);
|
||||
return null;
|
||||
}
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
fun deserialize(str: String): SourceAuth {
|
||||
private fun deserialize(str: String): SourceAuth {
|
||||
val data = Json.decodeFromString<SerializedAuth>(str);
|
||||
return SourceAuth(data.cookieMap, data.headers);
|
||||
}
|
||||
|
||||
+3
-15
@@ -1,7 +1,5 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
return EncryptionProvider.instance.encrypt(serialize());
|
||||
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "SourceAuth";
|
||||
val TAG = "SourceCaptchaData";
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
||||
if(encrypted == null)
|
||||
return null;
|
||||
|
||||
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
|
||||
try {
|
||||
return deserialize(decrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize authentication", ex);
|
||||
return null;
|
||||
}
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
fun deserialize(str: String): SourceCaptchaData {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.encryption.GEncryptionProvider
|
||||
import com.futo.platformplayer.encryption.GEncryptionProviderV0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.Exception
|
||||
|
||||
@Serializable
|
||||
data class SourceEncrypted(
|
||||
val encrypted: String,
|
||||
val version: Int = GEncryptionProvider.version
|
||||
) {
|
||||
fun toJson(): String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromDecrypted(serializer: () -> String): SourceEncrypted {
|
||||
return SourceEncrypted(GEncryptionProvider.instance.encrypt(serializer()));
|
||||
}
|
||||
|
||||
fun <T> decryptEncrypted(encrypted: String?, deserializer: (decrypted: String) -> T): T? {
|
||||
if(encrypted == null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
val encryptedSourceAuth = Json.decodeFromString<SourceEncrypted>(encrypted)
|
||||
if (encryptedSourceAuth.version != GEncryptionProvider.version) {
|
||||
throw Exception("Invalid encryption version.");
|
||||
}
|
||||
|
||||
val decrypted = GEncryptionProvider.instance.decrypt(encryptedSourceAuth.encrypted);
|
||||
try {
|
||||
return deserializer(decrypted);
|
||||
} catch(ex: Throwable) {
|
||||
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
|
||||
return null;
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
//Try to fall back to old mechanism, remove this eventually
|
||||
if (!encrypted.contains("version")) {
|
||||
val decrypted = GEncryptionProviderV0.instance.decrypt(encrypted);
|
||||
try {
|
||||
return deserializer(decrypted);
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -41,10 +41,12 @@ class SourcePluginConfig(
|
||||
val constants: HashMap<String, String> = hashMapOf(),
|
||||
|
||||
//TODO: These should be vals...but prob for serialization reasons cannot be changed.
|
||||
var platformUrl: String? = null,
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf()
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
@@ -142,7 +144,10 @@ class SourcePluginConfig(
|
||||
val description: String,
|
||||
val type: String,
|
||||
val default: String? = null,
|
||||
val variable: String? = null
|
||||
val variable: String? = null,
|
||||
val dependency: String? = null,
|
||||
val warningDialog: String? = null,
|
||||
val options: List<String>? = null
|
||||
) {
|
||||
@kotlinx.serialization.Transient
|
||||
val variableOrName: String get() = variable ?: name;
|
||||
|
||||
+24
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -79,6 +80,29 @@ class SourcePluginDescriptor {
|
||||
var enableSearch: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
|
||||
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
|
||||
var rateLimitSubs: Int = 0;
|
||||
|
||||
fun getSubRateLimit(): Int {
|
||||
return when(rateLimitSubs) {
|
||||
0 -> -1
|
||||
1 -> 25
|
||||
2 -> 50
|
||||
3 -> 75
|
||||
4 -> 100
|
||||
5 -> 125
|
||||
6 -> 150
|
||||
7 -> 200
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun loadDefaults(config: SourcePluginConfig) {
|
||||
|
||||
+10
-2
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||
_jsClient = jsClient;
|
||||
_jsConfig = config;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
|
||||
@@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||
if(_jsClient != null)
|
||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
|
||||
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
|
||||
|
||||
return newBuilder?.let { it.build() } ?: request;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
|
||||
ContentType.POST -> JSPost(config, obj);
|
||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -12,10 +12,10 @@ import com.futo.platformplayer.getOrThrow
|
||||
class JSChapter : IChapter {
|
||||
override val name: String;
|
||||
override val type: ChapterType;
|
||||
override val timeStart: Int;
|
||||
override val timeEnd: Int;
|
||||
override val timeStart: Double;
|
||||
override val timeEnd: Double;
|
||||
|
||||
constructor(name: String, timeStart: Int, timeEnd: Int, type: ChapterType = ChapterType.NORMAL) {
|
||||
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
|
||||
this.name = name;
|
||||
this.timeStart = timeStart;
|
||||
this.timeEnd = timeEnd;
|
||||
@@ -29,8 +29,8 @@ class JSChapter : IChapter {
|
||||
|
||||
val name = obj.getOrThrow<String>(config,"name", context);
|
||||
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
|
||||
val timeStart = obj.getOrThrow<Int>(config, "timeStart", context);
|
||||
val timeEnd = obj.getOrThrow<Int>(config, "timeEnd", context);
|
||||
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
|
||||
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
|
||||
|
||||
return JSChapter(name, timeStart, timeEnd, type);
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
|
||||
//TODO: Refactor into video-only
|
||||
class JSLockedContent: IPlatformLockedContent, JSContent {
|
||||
|
||||
override val contentType: ContentType get() = ContentType.LOCKED;
|
||||
override val lockContentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override val lockDescription: String?;
|
||||
override val unlockUrl: String?;
|
||||
|
||||
override val contentName: String?;
|
||||
override val contentThumbnails: Thumbnails;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformLockedContent";
|
||||
|
||||
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
|
||||
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
|
||||
return@let Thumbnails.fromV8(config, it);
|
||||
} ?: Thumbnails();
|
||||
|
||||
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
|
||||
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
|
||||
val previousResults = _lastResults?.let {
|
||||
if(!_resultChanged)
|
||||
return@let it;
|
||||
@@ -70,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
if(previousResults != null)
|
||||
return previousResults;
|
||||
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
|
||||
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
||||
val sameItems = results.filter { isSameItem(result, it) };
|
||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
||||
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first()
|
||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
|
||||
|
||||
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
||||
}
|
||||
|
||||
@@ -4,35 +4,34 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.media.MediaSession2Service.MediaNotification
|
||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateNotifications
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) :
|
||||
class BackgroundWorker(private val appContext: Context, private val workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
if(StateApp.instance.isMainActive) {
|
||||
if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
|
||||
Logger.i("BackgroundWorker", "CANCELLED");
|
||||
return Result.success();
|
||||
}
|
||||
@@ -47,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
this.setSound(null, null);
|
||||
};
|
||||
notificationManager.createNotificationChannel(notificationChannel);
|
||||
val contentChannel = StateNotifications.instance.contentNotifChannel
|
||||
notificationManager.createNotificationChannel(contentChannel);
|
||||
try {
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel);
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
exception = ex;
|
||||
@@ -70,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
}
|
||||
|
||||
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("Grayjay")
|
||||
.setContentText("Updating subscriptions...")
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id)
|
||||
.setChannelId(backgroundChannel.id)
|
||||
.setProgress(1, 0, true);
|
||||
|
||||
manager.notify(12, notif.build());
|
||||
@@ -86,9 +87,11 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
val newSubChanges = hashSetOf<Subscription>();
|
||||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val threeDays = now.minusDays(4);
|
||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||
withContext(Dispatchers.IO) {
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
|
||||
|
||||
synchronized(manager) {
|
||||
@@ -103,29 +106,37 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
synchronized(newSubChanges) {
|
||||
if(!newSubChanges.contains(sub)) {
|
||||
newSubChanges.add(sub);
|
||||
if(sub.doNotifications)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
if(sub.doNotifications) {
|
||||
if(content.datetime != null) {
|
||||
if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification)
|
||||
StateNotifications.instance.scheduleContentNotification(applicationContext, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
newItems.add(content);
|
||||
}
|
||||
});
|
||||
|
||||
//Only for testing notifications
|
||||
val testNotifs = 0;
|
||||
if(contentNotifs.size == 0 && testNotifs > 0) {
|
||||
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
|
||||
.take(testNotifs).forEach {
|
||||
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
manager.cancel(12);
|
||||
|
||||
if(newItems.size > 0) {
|
||||
if(contentNotifs.size > 0) {
|
||||
try {
|
||||
val items = contentNotifs.take(5).toList()
|
||||
for(i in items.indices) {
|
||||
val contentNotif = items.get(i);
|
||||
manager.notify(13 + i, NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New video by [${contentNotif.first.channel.name}]")
|
||||
.setContentText("${contentNotif.second.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, contentNotif.second.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id).build());
|
||||
StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.futo.platformplayer.builders
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
class HlsBuilder {
|
||||
companion object{
|
||||
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
|
||||
val hlsBuilder = StringWriter()
|
||||
PrintWriter(hlsBuilder).use { writer ->
|
||||
writer.println("#EXTM3U")
|
||||
|
||||
// Audio
|
||||
if (audioSource != null && audioUrl != null) {
|
||||
val audioFormat = audioSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"")
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (subtitleSource != null && subtitleUrl != null) {
|
||||
val subtitleFormat = subtitleSource.format ?: "text/vtt"
|
||||
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"")
|
||||
}
|
||||
|
||||
// Video
|
||||
val videoFormat = vidSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
|
||||
writer.println(vidUrl.replace("&", "&"))
|
||||
}
|
||||
|
||||
return hlsBuilder.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,60 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.toSafeFileName
|
||||
import com.futo.polycentric.core.toUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.toList
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ChannelContentCache {
|
||||
private val _targetCacheSize = 3000;
|
||||
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
|
||||
val _channelContents = HashMap(_channelCacheDir.listFiles()
|
||||
.filter { it.isDirectory }
|
||||
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load()) });
|
||||
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
|
||||
init {
|
||||
val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
|
||||
val initializeTime = measureTimeMillis {
|
||||
_channelContents = HashMap(allFiles
|
||||
.filter { it.isDirectory }
|
||||
.parallelStream().map {
|
||||
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load())
|
||||
}.toList().associate { it })
|
||||
}
|
||||
val minDays = OffsetDateTime.now().minusDays(10);
|
||||
val totalItems = _channelContents.map { it.value.count() }.sum();
|
||||
val toTrim = totalItems - _targetCacheSize;
|
||||
val trimmed: Int;
|
||||
if(toTrim > 0) {
|
||||
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
|
||||
.sortedBy { it.datetime!! }.take(toTrim);
|
||||
for(content in redundantContent)
|
||||
uncacheContent(content);
|
||||
trimmed = redundantContent.size;
|
||||
}
|
||||
else trimmed = 0;
|
||||
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
|
||||
}
|
||||
|
||||
fun 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();
|
||||
@@ -38,7 +79,9 @@ class ChannelContentCache {
|
||||
return PlatformContentPager(items, Math.min(150, items.size));
|
||||
}
|
||||
fun getSubscriptionCachePager(): DedupContentPager {
|
||||
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||
val allUrls = subs.map {
|
||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
||||
if(!otherUrls.contains(it.channel.url))
|
||||
@@ -46,6 +89,7 @@ class ChannelContentCache {
|
||||
else
|
||||
return@map otherUrls;
|
||||
}.flatten().distinct();
|
||||
Logger.i(TAG, "Subscriptions CachePager compiling");
|
||||
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = _channelContents
|
||||
@@ -55,10 +99,14 @@ class ChannelContentCache {
|
||||
val items = validStores.flatMap { it.getItems() }
|
||||
.sortedByDescending { it.datetime };
|
||||
|
||||
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
fun uncacheContent(content: SerializedPlatformContent) {
|
||||
val store = getContentStore(content);
|
||||
store?.delete(content);
|
||||
}
|
||||
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { cacheContent(it) };
|
||||
}
|
||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||
@@ -66,14 +114,14 @@ class ChannelContentCache {
|
||||
return false;
|
||||
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
val store = synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
if(channelStore == null) {
|
||||
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}");
|
||||
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, channelStore);
|
||||
val store = getContentStore(channelId).let {
|
||||
if(it == null) {
|
||||
Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
|
||||
val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, store);
|
||||
return@let store;
|
||||
}
|
||||
return@synchronized channelStore;
|
||||
else return@let it;
|
||||
}
|
||||
val serialized = SerializedPlatformContent.fromContent(content);
|
||||
val existing = store.findItems { it.url == content.url };
|
||||
@@ -88,6 +136,17 @@ class ChannelContentCache {
|
||||
return existing.isEmpty();
|
||||
}
|
||||
|
||||
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
return getContentStore(channelId);
|
||||
}
|
||||
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
|
||||
return synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
return@synchronized channelStore;
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ChannelCache";
|
||||
|
||||
@@ -95,10 +154,11 @@ class ChannelContentCache {
|
||||
private var _instance: ChannelContentCache? = null;
|
||||
val instance: ChannelContentCache get() {
|
||||
synchronized(_lock) {
|
||||
if(_instance == null)
|
||||
if(_instance == null) {
|
||||
_instance = ChannelContentCache();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
return _instance!!;
|
||||
}
|
||||
|
||||
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
@@ -114,7 +174,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -134,7 +194,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription results");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||
|
||||
time = resumePosition;
|
||||
_streamType = streamType;
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||
import com.futo.platformplayer.api.http.server.handlers.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
@@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.InetAddress
|
||||
@@ -45,6 +47,7 @@ class StateCasting {
|
||||
val onActiveDevicePlayChanged = Event1<Boolean>();
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
@@ -331,20 +334,25 @@ class StateCasting {
|
||||
}
|
||||
|
||||
if (sourceCount > 1) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); };
|
||||
ad.stopCasting();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||
}
|
||||
} else {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (ad is FastCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -353,19 +361,35 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (videoSource is IVideoUrlSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
else if(videoSource is IHLSManifestSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (audioSource is IAudioUrlSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
else if(audioSource is IHLSManifestAudioSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (videoSource is LocalVideoSource)
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, resumePosition);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition);
|
||||
else if (audioSource is LocalAudioSource)
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition);
|
||||
else {
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
@@ -402,10 +426,18 @@ class StateCasting {
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun castVideoIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castAudioIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
@@ -424,7 +456,7 @@ class StateCasting {
|
||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
@@ -440,11 +472,106 @@ class StateCasting {
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
val videoPath = "/video-${id}"
|
||||
val audioPath = "/audio-${id}"
|
||||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val hlsUrl = url + hlsPath
|
||||
val videoUrl = url + videoPath
|
||||
val audioUrl = url + audioPath
|
||||
val subtitleUrl = url + subtitlePath
|
||||
|
||||
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = videoSource.duration
|
||||
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
|
||||
videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null)))
|
||||
}
|
||||
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
|
||||
}
|
||||
|
||||
if (subtitleSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
|
||||
}
|
||||
|
||||
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
|
||||
_castServer.addHandler(
|
||||
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
|
||||
"application/vnd.apple.mpegurl")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castLocalHls")
|
||||
|
||||
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
|
||||
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
|
||||
|
||||
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
|
||||
}
|
||||
|
||||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -486,7 +613,7 @@ class StateCasting {
|
||||
}
|
||||
if (subtitleSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true)
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
@@ -505,7 +632,7 @@ class StateCasting {
|
||||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
val subtitlePath = "/subtitle-${id}";
|
||||
|
||||
@@ -547,13 +674,277 @@ class StateCasting {
|
||||
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
||||
}
|
||||
|
||||
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
val hlsUrl = url + hlsPath
|
||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||
|
||||
_castServer.addHandler(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 newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
|
||||
|
||||
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
|
||||
val playlistId = UUID.randomUUID();
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
val newPlaylistUrl = url + newPlaylistPath;
|
||||
|
||||
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
|
||||
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
||||
newPlaylistUrl,
|
||||
variantPlaylistRef.streamInfo
|
||||
))
|
||||
}
|
||||
|
||||
for (mediaRendition in masterPlaylist.mediaRenditions) {
|
||||
val playlistId = UUID.randomUUID()
|
||||
|
||||
var newPlaylistUrl: String? = null
|
||||
if (mediaRendition.uri != null) {
|
||||
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||
newPlaylistUrl = url + newPlaylistPath
|
||||
|
||||
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
}
|
||||
|
||||
newMediaRenditions.add(HLS.MediaRendition(
|
||||
mediaRendition.type,
|
||||
newPlaylistUrl,
|
||||
mediaRendition.groupID,
|
||||
mediaRendition.language,
|
||||
mediaRendition.name,
|
||||
mediaRendition.isDefault,
|
||||
mediaRendition.isAutoSelect,
|
||||
mediaRendition.isForced
|
||||
))
|
||||
}
|
||||
|
||||
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
|
||||
|
||||
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
|
||||
|
||||
//ChromeCast is sometimes funky with resume position 0
|
||||
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition;
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
|
||||
|
||||
return listOf(hlsUrl);
|
||||
}
|
||||
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
val newSegments = arrayListOf<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||
}
|
||||
} else {
|
||||
newSegments.addAll(variantPlaylist.segments)
|
||||
}
|
||||
|
||||
return HLS.VariantPlaylist(
|
||||
variantPlaylist.version,
|
||||
variantPlaylist.targetDuration,
|
||||
variantPlaylist.mediaSequence,
|
||||
variantPlaylist.discontinuitySequence,
|
||||
variantPlaylist.programDateTime,
|
||||
variantPlaylist.playlistType,
|
||||
newSegments
|
||||
)
|
||||
}
|
||||
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||
if (segment is HLS.MediaSegment) {
|
||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||
val newSegmentUrl = url + newSegmentPath;
|
||||
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castProxiedHlsVariant")
|
||||
}
|
||||
|
||||
return HLS.MediaSegment(
|
||||
segment.duration,
|
||||
newSegmentUrl
|
||||
)
|
||||
} else {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
||||
val hlsUrl = url + hlsPath;
|
||||
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||
|
||||
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
if (audioSource != null) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath
|
||||
|
||||
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
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(
|
||||
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) {
|
||||
return@withContext subtitleSource.getSubtitlesURI();
|
||||
} else null;
|
||||
|
||||
var subtitlesUrl: String? = null;
|
||||
if (subtitlesUri != null) {
|
||||
val subtitlePath = "/subtitles-${id}"
|
||||
if(subtitlesUri.scheme == "file") {
|
||||
var content: String? = null;
|
||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||
inputStream?.use { stream ->
|
||||
val reader = stream.bufferedReader();
|
||||
content = reader.use { it.readText() };
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandler(
|
||||
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;
|
||||
} else {
|
||||
subtitlesUrl = subtitlesUri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitlesUrl != null) {
|
||||
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
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))
|
||||
}
|
||||
|
||||
if (videoSource != null) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath
|
||||
|
||||
val duration = videoSource.duration
|
||||
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
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,
|
||||
"${videoSource.width}x${videoSource.height}",
|
||||
videoSource.codec,
|
||||
null,
|
||||
null,
|
||||
if (audioSource != null) "audio" else null,
|
||||
if (subtitleSource != null) "subtitles" else null,
|
||||
null, null)))
|
||||
|
||||
_castServer.addHandler(
|
||||
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(
|
||||
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());
|
||||
|
||||
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = ad !is FastCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
Logger.i(TAG, "DASH url: $url");
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -562,6 +953,8 @@ class StateCasting {
|
||||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val dashUrl = url + dashPath;
|
||||
Logger.i(TAG, "DASH url: $dashUrl");
|
||||
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
||||
|
||||
@@ -587,6 +980,7 @@ class StateCasting {
|
||||
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;
|
||||
@@ -600,33 +994,37 @@ class StateCasting {
|
||||
"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(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(videoPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
).withTag("cast");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(audioPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alivcontexte"))
|
||||
)
|
||||
.withTag("cast");
|
||||
}
|
||||
|
||||
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
|
||||
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
||||
|
||||
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
|
||||
@@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
val config = context.readContentJson<SourcePluginConfig>()
|
||||
try {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config);
|
||||
|
||||
val client = JSHttpClient(null, null, null, config);
|
||||
val clientAuth = JSHttpClient(null, null, null, config);
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
|
||||
|
||||
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
}
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it));
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
context.respondCode(200, "Login started");
|
||||
@@ -287,7 +292,6 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
@HttpPOST("/plugin/remoteCall")
|
||||
fun pluginRemoteCall(context: HttpContext) {
|
||||
try {
|
||||
val parameters = context.readContentString();
|
||||
val objId = context.query.get("id")
|
||||
val method = context.query.get("method")
|
||||
|
||||
@@ -299,16 +303,24 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(400, "Missing method");
|
||||
return;
|
||||
}
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
|
||||
val parameters = context.readContentString();
|
||||
|
||||
val remoteObj = getRemoteObject(objId);
|
||||
val paras = JsonParser.parseString(parameters);
|
||||
if(!paras.isJsonArray)
|
||||
throw IllegalArgumentException("Expected json array as body");
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
val callResult = remoteObj.call(method, paras as JsonArray);
|
||||
val json = wrapRemoteResult(callResult, false);
|
||||
context.respondCode(200, json, "application/json");
|
||||
}
|
||||
catch(invocation: InvocationTargetException) {
|
||||
val innerException = invocation.targetException;
|
||||
Logger.e("DeveloperEndpoints", innerException.message, innerException);
|
||||
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
|
||||
}
|
||||
catch(ilEx: IllegalArgumentException) {
|
||||
if(ilEx.message?.contains("does not exist") ?: false) {
|
||||
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
||||
|
||||
@@ -95,6 +95,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonUpdate.visibility = Button.GONE;
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
|
||||
Logger.i(TAG, "Keep screen on set update")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_text.text = context.resources.getText(R.string.downloading_update);
|
||||
@@ -178,6 +180,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
Logger.i(TAG, "Keep screen on unset install")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -85,6 +86,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
if (_editComment.text.isBlank()) {
|
||||
UIDialogs.toast(context, "Comment should not be blank.");
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
val comment = _editComment.text.toString();
|
||||
val processHandle = StatePolycentric.instance.processHandle!!
|
||||
val eventPointer = processHandle.post(comment, null, ref)
|
||||
@@ -92,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers()
|
||||
processHandle.fullyBackfillServersAnnounceExceptions()
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||
|
||||
@@ -134,6 +134,8 @@ class ImportDialog : AlertDialog {
|
||||
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
|
||||
Logger.i(TAG, "Keep screen on set import")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_updateSpinner.drawable?.assume<Animatable>()?.start();
|
||||
@@ -201,6 +203,7 @@ class ImportDialog : AlertDialog {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update import UI.", e)
|
||||
} finally {
|
||||
Logger.i(TAG, "Keep screen on unset update")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ class MigrateDialog : AlertDialog {
|
||||
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
Logger.i(TAG, "Keep screen on set restore")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_updateSpinner.drawable?.assume<Animatable>()?.start();
|
||||
@@ -214,6 +215,7 @@ class MigrateDialog : AlertDialog {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update import UI.", e)
|
||||
} finally {
|
||||
Logger.i(TAG, "Keep screen on unset restore")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -43,7 +48,7 @@ class VideoExport {
|
||||
this.subtitleSource = subtitleSource;
|
||||
}
|
||||
|
||||
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope {
|
||||
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
|
||||
if(isCancelled) throw CancellationException("Export got cancelled");
|
||||
|
||||
val v = videoSource;
|
||||
@@ -55,34 +60,47 @@ class VideoExport {
|
||||
if (a != null) sourceCount++;
|
||||
if (s != null) sourceCount++;
|
||||
|
||||
var outputFile: File? = null;
|
||||
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
|
||||
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
|
||||
val moviesGrayjay = File(moviesRoot, "Grayjay");
|
||||
val musicGrayjay = File(musicRoot, "Grayjay");
|
||||
if(!moviesGrayjay.exists())
|
||||
moviesGrayjay.mkdirs();
|
||||
if(!musicGrayjay.exists())
|
||||
musicGrayjay.mkdirs();
|
||||
|
||||
val outputFile: DocumentFile?;
|
||||
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
|
||||
if (sourceCount > 1) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = File(moviesGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile("video/mp4", outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Combining video and audio through FFMPEG.");
|
||||
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
|
||||
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
|
||||
try {
|
||||
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete();
|
||||
}
|
||||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = File(moviesGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile(v.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
|
||||
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
|
||||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = File(musicGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
|
||||
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
|
||||
outputFile = f;
|
||||
} else {
|
||||
throw Exception("Cannot export when no audio or video source is set.");
|
||||
@@ -179,10 +197,9 @@ class VideoExport {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
|
||||
private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var inputStream: FileInputStream? = null
|
||||
var outputStream: FileOutputStream? = null
|
||||
|
||||
try {
|
||||
val srcFile = File(fromPath)
|
||||
@@ -190,17 +207,7 @@ class VideoExport {
|
||||
throw IOException("Source file not found.")
|
||||
}
|
||||
|
||||
val dstFile = File(toPath)
|
||||
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
|
||||
|
||||
if (!parentDir.exists()) {
|
||||
if (!parentDir.mkdirs()) {
|
||||
throw IOException("Failed to create destination directory.")
|
||||
}
|
||||
}
|
||||
|
||||
inputStream = FileInputStream(srcFile)
|
||||
outputStream = FileOutputStream(dstFile)
|
||||
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val totalBytes = srcFile.length()
|
||||
@@ -221,7 +228,6 @@ class VideoExport {
|
||||
throw IOException("Error occurred while copying file: ${e.message}", e)
|
||||
} finally {
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.futo.platformplayer.encryption
|
||||
|
||||
class GEncryptionProvider {
|
||||
companion object {
|
||||
val instance: GEncryptionProviderV1 = GEncryptionProviderV1.instance;
|
||||
val version = 1;
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -8,9 +8,8 @@ import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class EncryptionProvider {
|
||||
class GEncryptionProviderV0 {
|
||||
private val _keyStore: KeyStore;
|
||||
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
|
||||
|
||||
@@ -25,45 +24,43 @@ class EncryptionProvider {
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build());
|
||||
|
||||
|
||||
keyGenerator.generateKey();
|
||||
}
|
||||
}
|
||||
|
||||
fun encrypt(decrypted: String, password: String? = null): String {
|
||||
val encodedBytes = encrypt(decrypted.toByteArray(), password);
|
||||
fun encrypt(decrypted: String): String {
|
||||
val encodedBytes = encrypt(decrypted.toByteArray());
|
||||
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
|
||||
return encrypted;
|
||||
}
|
||||
fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray {
|
||||
fun encrypt(decrypted: ByteArray): ByteArray {
|
||||
val c: Cipher = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val encodedBytes: ByteArray = c.doFinal(decrypted);
|
||||
return encodedBytes;
|
||||
}
|
||||
|
||||
fun decrypt(encrypted: String, password: String? = null): String {
|
||||
fun decrypt(encrypted: String): String {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
|
||||
return decrypted;
|
||||
}
|
||||
fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray {
|
||||
fun decrypt(encrypted: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
return c.doFinal(encrypted);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val instance: EncryptionProvider = EncryptionProvider();
|
||||
val instance: GEncryptionProviderV0 = GEncryptionProviderV0();
|
||||
|
||||
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
|
||||
private const val AndroidKeyStore = "AndroidKeyStore";
|
||||
private const val KEY_ALIAS = "FUTOMedia_Key";
|
||||
private const val AES_MODE = "AES/GCM/NoPadding";
|
||||
private val TAG = "EncryptionProvider";
|
||||
private const val TAG_LENGTH = 128
|
||||
private val TAG = "GEncryptionProviderV0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.futo.platformplayer.encryption
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.Key
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class GEncryptionProviderV1 {
|
||||
private val _keyStore: KeyStore;
|
||||
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
|
||||
|
||||
constructor() {
|
||||
_keyStore = KeyStore.getInstance(AndroidKeyStore);
|
||||
_keyStore.load(null);
|
||||
|
||||
if (!_keyStore.containsAlias(KEY_ALIAS)) {
|
||||
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore)
|
||||
keyGenerator.init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build());
|
||||
|
||||
keyGenerator.generateKey();
|
||||
}
|
||||
}
|
||||
|
||||
fun encrypt(decrypted: String): String {
|
||||
val encrypted = encrypt(decrypted.toByteArray());
|
||||
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
|
||||
return encoded;
|
||||
}
|
||||
fun encrypt(decrypted: ByteArray): ByteArray {
|
||||
val ivBytes = generateIv()
|
||||
val c: Cipher = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
|
||||
val encodedBytes: ByteArray = c.doFinal(decrypted);
|
||||
return ivBytes + encodedBytes;
|
||||
}
|
||||
|
||||
fun decrypt(data: String): String {
|
||||
val bytes = Base64.decode(data, Base64.DEFAULT)
|
||||
return String(decrypt(bytes));
|
||||
}
|
||||
fun decrypt(bytes: ByteArray): ByteArray {
|
||||
val encrypted = bytes.sliceArray(IntRange(IV_SIZE, bytes.size - 1))
|
||||
val ivBytes = bytes.sliceArray(IntRange(0, IV_SIZE - 1))
|
||||
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
|
||||
return c.doFinal(encrypted);
|
||||
}
|
||||
|
||||
private fun generateIv(): ByteArray {
|
||||
val r = SecureRandom()
|
||||
val ivBytes = ByteArray(IV_SIZE)
|
||||
r.nextBytes(ivBytes)
|
||||
return ivBytes
|
||||
}
|
||||
|
||||
companion object {
|
||||
val instance: GEncryptionProviderV1 = GEncryptionProviderV1();
|
||||
|
||||
private const val AndroidKeyStore = "AndroidKeyStore";
|
||||
private const val KEY_ALIAS = "FUTOMedia_Key";
|
||||
private const val AES_MODE = "AES/GCM/NoPadding";
|
||||
private const val IV_SIZE = 12;
|
||||
private const val TAG_LENGTH = 128
|
||||
private val TAG = "GEncryptionProviderV1";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.futo.platformplayer.encryption
|
||||
|
||||
class GPasswordEncryptionProvider {
|
||||
companion object {
|
||||
val version = 1;
|
||||
val instance = GPasswordEncryptionProviderV1.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.encryption
|
||||
|
||||
import android.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class GPasswordEncryptionProviderV0 {
|
||||
private val _key: SecretKeySpec;
|
||||
|
||||
constructor(password: String) {
|
||||
_key = SecretKeySpec(password.toByteArray(), "AES");
|
||||
}
|
||||
|
||||
fun encrypt(decrypted: String): String {
|
||||
val encodedBytes = encrypt(decrypted.toByteArray());
|
||||
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
|
||||
return encrypted;
|
||||
}
|
||||
fun encrypt(decrypted: ByteArray): ByteArray {
|
||||
val c: Cipher = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.ENCRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val encodedBytes: ByteArray = c.doFinal(decrypted);
|
||||
return encodedBytes;
|
||||
}
|
||||
|
||||
fun decrypt(encrypted: String): String {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
|
||||
return decrypted;
|
||||
}
|
||||
fun decrypt(encrypted: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
return c.doFinal(encrypted);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
|
||||
private const val TAG_LENGTH = 128
|
||||
private const val AES_MODE = "AES/GCM/NoPadding";
|
||||
private val TAG = "GPasswordEncryptionProviderV0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.futo.platformplayer.encryption
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class GPasswordEncryptionProviderV1 {
|
||||
fun encrypt(decrypted: String, password: String): String {
|
||||
val encrypted = encrypt(decrypted.toByteArray(), password);
|
||||
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
fun encrypt(decrypted: ByteArray, password: String): ByteArray {
|
||||
val saltBytes = generateSalt()
|
||||
val ivBytes = generateIv()
|
||||
val c: Cipher = Cipher.getInstance(AES_MODE);
|
||||
val key = deriveKeyFromPassword(password, saltBytes)
|
||||
|
||||
c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
|
||||
val encodedBytes: ByteArray = c.doFinal(decrypted);
|
||||
return saltBytes + ivBytes + encodedBytes;
|
||||
}
|
||||
|
||||
fun decrypt(data: String, password: String): String {
|
||||
val bytes = Base64.decode(data, Base64.DEFAULT)
|
||||
return String(decrypt(bytes, password));
|
||||
}
|
||||
fun decrypt(bytes: ByteArray, password: String): ByteArray {
|
||||
val encrypted = bytes.sliceArray(IntRange(SALT_SIZE + IV_SIZE, bytes.size - 1))
|
||||
val ivBytes = bytes.sliceArray(IntRange(SALT_SIZE, SALT_SIZE + IV_SIZE - 1))
|
||||
val saltBytes = bytes.sliceArray(IntRange(0, SALT_SIZE - 1))
|
||||
val key = deriveKeyFromPassword(password, saltBytes)
|
||||
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
|
||||
return c.doFinal(encrypted);
|
||||
}
|
||||
|
||||
private fun deriveKeyFromPassword(password: String, salt: ByteArray): SecretKeySpec {
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
|
||||
val tmp = factory.generateSecret(spec)
|
||||
return SecretKeySpec(tmp.encoded, "AES")
|
||||
}
|
||||
|
||||
private fun generateSalt(): ByteArray {
|
||||
val random = SecureRandom()
|
||||
val salt = ByteArray(SALT_SIZE)
|
||||
random.nextBytes(salt)
|
||||
return salt
|
||||
}
|
||||
|
||||
private fun generateIv(): ByteArray {
|
||||
val r = SecureRandom()
|
||||
val ivBytes = ByteArray(IV_SIZE)
|
||||
r.nextBytes(ivBytes)
|
||||
return ivBytes
|
||||
}
|
||||
|
||||
companion object {
|
||||
val instance = GPasswordEncryptionProviderV1();
|
||||
private const val AES_MODE = "AES/GCM/NoPadding";
|
||||
private const val IV_SIZE = 12
|
||||
private const val SALT_SIZE = 16
|
||||
private const val ITERATION_COUNT = 2 * 65536
|
||||
private const val KEY_LENGTH = 256
|
||||
private const val TAG_LENGTH = 128
|
||||
private val TAG = "GPasswordEncryptionProviderV1";
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -4,7 +4,6 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -12,8 +11,8 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
@@ -25,6 +24,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -33,8 +33,9 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -54,9 +55,11 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
val onUrlClicked = Event1<String>();
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
@@ -73,10 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
}
|
||||
|
||||
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
||||
return@TaskHandler getContentPager(it);
|
||||
}).success {
|
||||
val livePager = getContentPager(it);
|
||||
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
||||
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
|
||||
else livePager;
|
||||
}).success { livePager ->
|
||||
setLoading(false);
|
||||
setPager(it);
|
||||
|
||||
setPager(livePager);
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
@@ -146,12 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply {
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
|
||||
}
|
||||
|
||||
_llmVideo = LinearLayoutManager(view.context);
|
||||
|
||||
+20
-33
@@ -1,21 +1,20 @@
|
||||
package com.futo.platformplayer.fragment.channel.tab
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.SupportView
|
||||
|
||||
|
||||
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
||||
private var _buttonStore: BigButton? = null;
|
||||
private var _supportView: SupportView? = null
|
||||
private var _textMonetization: TextView? = null
|
||||
|
||||
private var _lastChannel: IPlatformChannel? = null;
|
||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
@@ -24,51 +23,39 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
|
||||
_buttonStore = view.findViewById(R.id.button_store);
|
||||
|
||||
_buttonStore?.onClick?.subscribe {
|
||||
_lastPolycentricProfile?.systemState?.store?.let {
|
||||
try {
|
||||
val uri = Uri.parse(it);
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = uri
|
||||
startActivity(intent)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to open URI: '${it}'.", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
_supportView = view.findViewById(R.id.support);
|
||||
_textMonetization = view.findViewById(R.id.text_monetization);
|
||||
|
||||
_lastChannel?.also {
|
||||
setChannel(it);
|
||||
};
|
||||
|
||||
_lastPolycentricProfile?.also {
|
||||
setPolycentricProfile(it, animate = false);
|
||||
}
|
||||
|
||||
_supportView?.visibility = View.GONE;
|
||||
_textMonetization?.visibility = View.GONE;
|
||||
setPolycentricProfile(_lastPolycentricProfile, animate = false);
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView();
|
||||
_buttonStore = null;
|
||||
_supportView = null;
|
||||
_textMonetization = null;
|
||||
}
|
||||
|
||||
override fun setChannel(channel: IPlatformChannel) {
|
||||
_lastChannel = channel;
|
||||
_buttonStore?.visibility = View.GONE;
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
|
||||
_lastPolycentricProfile = polycentricProfile;
|
||||
|
||||
if (polycentricProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (polycentricProfile.systemState.store.isNotEmpty()) {
|
||||
_buttonStore?.visibility = View.VISIBLE;
|
||||
_lastPolycentricProfile = polycentricProfile
|
||||
if (polycentricProfile != null) {
|
||||
_supportView?.setPolycentricProfile(polycentricProfile, animate)
|
||||
_supportView?.visibility = View.VISIBLE
|
||||
_textMonetization?.visibility = View.GONE
|
||||
} else {
|
||||
_supportView?.setPolycentricProfile(null, animate)
|
||||
_supportView?.visibility = View.GONE
|
||||
_textMonetization?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -225,7 +225,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
if (faqIndex != -1) {
|
||||
val button = buttons[faqIndex]
|
||||
buttons.removeAt(faqIndex)
|
||||
buttons.add(1, button)
|
||||
buttons.add(if (buttons.size == 1) 1 else 0, button)
|
||||
}
|
||||
|
||||
for (data in buttons) {
|
||||
@@ -252,8 +252,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
val defs = currentButtonDefinitions?.toMutableList() ?: return
|
||||
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
|
||||
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
|
||||
if (_buttonsVisible - 2 >= defs.size) {
|
||||
updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false);
|
||||
if (_buttonsVisible - 1 >= defs.size) {
|
||||
updateBottomMenuButtons(defs.toMutableList(), false);
|
||||
} else {
|
||||
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
|
||||
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
|
||||
|
||||
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
|
||||
val price = prices[currency.id]!!;
|
||||
val priceDecimal = (price.toDouble() / 100);
|
||||
withContext(Dispatchers.Main) {
|
||||
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal);
|
||||
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-7
@@ -170,6 +170,10 @@ class ChannelFragment : MainFragment() {
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
}
|
||||
_buttonSubscribe.onUnSubscribed.subscribe {
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
_buttonSubscriptionSettings.setOnClickListener {
|
||||
@@ -206,6 +210,9 @@ class ChannelFragment : MainFragment() {
|
||||
UIDialogs.toast(context, "Queued [$name]", false);
|
||||
}
|
||||
}
|
||||
adapter.onUrlClicked.subscribe { url ->
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
}
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when(contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
@@ -216,6 +223,12 @@ class ChannelFragment : MainFragment() {
|
||||
else -> {};
|
||||
}
|
||||
}
|
||||
adapter.onLongPress.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||
}
|
||||
}
|
||||
viewPager.adapter = adapter;
|
||||
|
||||
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
||||
@@ -382,14 +395,18 @@ class ChannelFragment : MainFragment() {
|
||||
});
|
||||
});
|
||||
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
}
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(channel);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
|
||||
+67
-31
@@ -1,6 +1,5 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
@@ -17,16 +16,16 @@ import com.futo.platformplayer.api.media.structures.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
|
||||
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import kotlin.math.floor
|
||||
|
||||
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
|
||||
@@ -37,6 +36,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
private var _previewsEnabled: Boolean = true;
|
||||
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
||||
protected lateinit var headerView: LinearLayout;
|
||||
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
|
||||
protected open val shouldShowTimeBar: Boolean get() = true
|
||||
|
||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
|
||||
@@ -57,39 +58,22 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
};
|
||||
headerView = v;
|
||||
|
||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply {
|
||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
|
||||
attachAdapterEvents(this);
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachAdapterEvents(adapter: PreviewContentListAdapter) {
|
||||
adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked);
|
||||
adapter.onUrlClicked.subscribe(this, this@ContentFeedView::onUrlClicked);
|
||||
adapter.onContentClicked.subscribe(this) { content, time ->
|
||||
this@ContentFeedView.onContentClicked(content, time);
|
||||
};
|
||||
adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) };
|
||||
adapter.onAddToClicked.subscribe(this) { content ->
|
||||
//TODO: Reconstruct search video from detail if search is null
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(content.url);
|
||||
if (fragment is HomeFragment) {
|
||||
val removeIndex = recyclerData.results.indexOf(content);
|
||||
if (removeIndex >= 0) {
|
||||
recyclerData.results.removeAt(removeIndex);
|
||||
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
|
||||
}
|
||||
}
|
||||
}),
|
||||
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
|
||||
{
|
||||
val newQueue = listOf(content) + recyclerData.results
|
||||
.filterIsInstance<IPlatformVideo>()
|
||||
.filter { it != content };
|
||||
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
|
||||
})
|
||||
);
|
||||
if(content is IPlatformVideo) {
|
||||
showVideoOptionsOverlay(content)
|
||||
}
|
||||
};
|
||||
adapter.onAddToQueueClicked.subscribe(this) {
|
||||
@@ -99,15 +83,61 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
|
||||
}
|
||||
};
|
||||
adapter.onLongPress.subscribe(this) {
|
||||
if (it is IPlatformVideo) {
|
||||
showVideoOptionsOverlay(it)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
val videoOptionsOverlay = _videoOptionsOverlay
|
||||
if (videoOptionsOverlay != null) {
|
||||
if (videoOptionsOverlay.isVisible) {
|
||||
videoOptionsOverlay.hide();
|
||||
_videoOptionsOverlay = null
|
||||
return true;
|
||||
}
|
||||
|
||||
_videoOptionsOverlay = null
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
|
||||
_overlayContainer.let {
|
||||
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(content.url);
|
||||
if (fragment is HomeFragment) {
|
||||
val removeIndex = recyclerData.results.indexOf(content);
|
||||
if (removeIndex >= 0) {
|
||||
recyclerData.results.removeAt(removeIndex);
|
||||
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
|
||||
}
|
||||
}
|
||||
}),
|
||||
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
|
||||
{
|
||||
val newQueue = listOf(content) + recyclerData.results
|
||||
.filterIsInstance<IPlatformVideo>()
|
||||
.filter { it != content };
|
||||
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private fun detachAdapterEvents() {
|
||||
val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return;
|
||||
adapter.onContentUrlClicked.remove(this);
|
||||
adapter.onUrlClicked.remove(this);
|
||||
adapter.onContentClicked.remove(this);
|
||||
adapter.onChannelClicked.remove(this);
|
||||
adapter.onAddToClicked.remove(this);
|
||||
adapter.onAddToQueueClicked.remove(this);
|
||||
adapter.onLongPress.remove(this);
|
||||
}
|
||||
|
||||
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
|
||||
@@ -137,11 +167,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
|
||||
protected open fun onContentClicked(content: IPlatformContent, time: Long) {
|
||||
if(content is IPlatformVideo) {
|
||||
StatePlayer.instance.clearQueue();
|
||||
if (Settings.instance.playback.shouldResumePreview(time))
|
||||
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
|
||||
else
|
||||
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
|
||||
if (StatePlayer.instance.hasQueue) {
|
||||
StatePlayer.instance.addToQueue(content)
|
||||
} else {
|
||||
if (Settings.instance.playback.shouldResumePreview(time))
|
||||
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
|
||||
else
|
||||
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
|
||||
}
|
||||
} else if (content is IPlatformPlaylist) {
|
||||
fragment.navigate<PlaylistFragment>(content);
|
||||
} else if (content is IPlatformPost) {
|
||||
@@ -159,6 +192,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
else -> {};
|
||||
}
|
||||
}
|
||||
protected open fun onUrlClicked(url: String) {
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
}
|
||||
|
||||
private fun playPreview() {
|
||||
if(feedStyle == FeedStyle.THUMBNAIL)
|
||||
|
||||
+16
-2
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.isHttpUrl
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -61,8 +62,15 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
_view = null;
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (_view?.onBackPressed() == true)
|
||||
return true
|
||||
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
@@ -76,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||
|
||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||
@@ -92,6 +101,8 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
Logger.w(TAG, "Failed to load results.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
}
|
||||
|
||||
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
@@ -143,7 +154,10 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
};
|
||||
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it, true);
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
setQuery(it, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -6,10 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Spinner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
|
||||
class CreatorsFragment : MainFragment() {
|
||||
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _spinnerSortBy: Spinner? = null;
|
||||
private var _overlayContainer: FrameLayout? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_creators, container, false);
|
||||
|
||||
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
|
||||
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
|
||||
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
|
||||
|
||||
_overlayContainer = view.findViewById(R.id.overlay_container);
|
||||
val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby);
|
||||
spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_spinnerSortBy = null;
|
||||
_overlayContainer = null;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
var filteredNextPageCounter = 0;
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
@@ -141,7 +142,15 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
val filteredResults = filterResults(it);
|
||||
recyclerData.results.addAll(filteredResults);
|
||||
recyclerData.resultsUnfiltered.addAll(it);
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
if(filteredResults.isEmpty()) {
|
||||
filteredNextPageCounter++
|
||||
if(filteredNextPageCounter <= 4)
|
||||
loadNextPage()
|
||||
}
|
||||
else {
|
||||
filteredNextPageCounter = 0;
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
}
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
|
||||
+12
-2
@@ -66,6 +66,13 @@ class HomeFragment : MainFragment() {
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (_view?.onBackPressed() == true)
|
||||
return true
|
||||
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
|
||||
@@ -78,7 +85,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
@@ -88,6 +95,7 @@ class HomeFragment : MainFragment() {
|
||||
private var _announcementsView: AnnouncementView;
|
||||
|
||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
_announcementsView = AnnouncementView(context, null).apply {
|
||||
@@ -122,6 +130,8 @@ class HomeFragment : MainFragment() {
|
||||
setLoading(false);
|
||||
};
|
||||
};
|
||||
|
||||
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
@@ -150,7 +160,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) };
|
||||
return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
|
||||
}
|
||||
|
||||
private fun loadResults() {
|
||||
|
||||
+18
-2
@@ -63,6 +63,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
private var _textSelectDeselectAll: TextView;
|
||||
private var _textNothingToImport: TextView;
|
||||
private var _textCounter: TextView;
|
||||
private var _textLoadMore: TextView;
|
||||
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
|
||||
private var _links: List<String> = listOf();
|
||||
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
|
||||
@@ -79,6 +80,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
_textNothingToImport = findViewById(R.id.nothing_to_import);
|
||||
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
|
||||
_textCounter = findViewById(R.id.text_select_counter);
|
||||
_textLoadMore = findViewById(R.id.text_load_more);
|
||||
_spinner = findViewById(R.id.channel_loader);
|
||||
|
||||
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
|
||||
@@ -120,6 +122,19 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
|
||||
loadNext();
|
||||
};
|
||||
|
||||
_textLoadMore.setOnClickListener {
|
||||
if (!_limitToastShown) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_textLoadMore.visibility = View.GONE;
|
||||
_limitToastShown = false;
|
||||
_counter = 0;
|
||||
load();
|
||||
};
|
||||
|
||||
_textLoadMore.visibility = View.GONE;
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
@@ -165,7 +180,8 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
if (_counter >= MAXIMUM_BATCH_SIZE) {
|
||||
if (!_limitToastShown) {
|
||||
_limitToastShown = true;
|
||||
UIDialogs.toast(context, "Stopped after {requestCount} to avoid rate limit, re-enter to import rest".replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
|
||||
_textLoadMore.visibility = View.VISIBLE;
|
||||
UIDialogs.toast(context, context.getString(R.string.stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more).replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -210,7 +226,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
|
||||
companion object {
|
||||
val TAG = "ImportSubscriptionsFragment";
|
||||
private const val MAXIMUM_BATCH_SIZE = 75;
|
||||
private const val MAXIMUM_BATCH_SIZE = 100;
|
||||
fun newInstance() = ImportSubscriptionsFragment().apply {}
|
||||
}
|
||||
}
|
||||
+7
@@ -44,6 +44,13 @@ class PlaylistSearchResultsFragment : MainFragment() {
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (_view?.onBackPressed() == true)
|
||||
return true
|
||||
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_view?.cleanup();
|
||||
|
||||
+39
-30
@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
@@ -54,6 +55,14 @@ class PlaylistsFragment : MainFragment() {
|
||||
_view?.onShown(parameter, isBack);
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (_view?.onBackPressed() == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PlaylistsView : LinearLayout {
|
||||
private val _fragment: PlaylistsFragment;
|
||||
@@ -64,6 +73,7 @@ class PlaylistsFragment : MainFragment() {
|
||||
private var _adapterWatchLater: VideoListHorizontalAdapter;
|
||||
private var _adapterPlaylist: PlaylistsAdapter;
|
||||
private var _layoutWatchlist: ConstraintLayout;
|
||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
@@ -92,41 +102,24 @@ class PlaylistsFragment : MainFragment() {
|
||||
recyclerPlaylists.adapter = _adapterPlaylist;
|
||||
recyclerPlaylists.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
|
||||
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
|
||||
buttonCreatePlaylist.setOnClickListener {
|
||||
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
|
||||
val playlist = Playlist(it, arrayListOf());
|
||||
playlists.add(0, playlist);
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
|
||||
_adapterPlaylist.notifyItemInserted(0);
|
||||
};
|
||||
};
|
||||
|
||||
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
|
||||
_adapterPlaylist.onPlay.subscribe { p ->
|
||||
StatePlayer.instance.setPlaylist(p, 0, true);
|
||||
};
|
||||
|
||||
addPlaylistOverlay.onOK.subscribe {
|
||||
val text = nameInput.text;
|
||||
if (text.isBlank()) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
val playlist = Playlist(text, arrayListOf());
|
||||
playlists.add(0, playlist);
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
|
||||
_adapterPlaylist.notifyItemInserted(0);
|
||||
addPlaylistOverlay.hide();
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
};
|
||||
|
||||
addPlaylistOverlay.onCancel.subscribe {
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
};
|
||||
|
||||
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
|
||||
buttonCreatePlaylist.setOnClickListener {
|
||||
addPlaylistOverlay.show();
|
||||
nameInput.activate();
|
||||
};
|
||||
|
||||
_appBar = findViewById(R.id.app_bar);
|
||||
_layoutWatchlist = findViewById(R.id.layout_watchlist);
|
||||
|
||||
@@ -142,12 +135,28 @@ class PlaylistsFragment : MainFragment() {
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
playlists.clear()
|
||||
playlists.addAll(StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
|
||||
playlists.addAll(
|
||||
StatePlaylists.instance.getPlaylists()
|
||||
.sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
|
||||
_adapterPlaylist.notifyDataSetChanged();
|
||||
|
||||
updateWatchLater();
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
val slideUpOverlay = _slideUpOverlay;
|
||||
if (slideUpOverlay != null) {
|
||||
if (slideUpOverlay.isVisible) {
|
||||
slideUpOverlay.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private fun updateWatchLater() {
|
||||
val watchList = StatePlaylists.instance.getWatchLater();
|
||||
if (watchList.isNotEmpty()) {
|
||||
|
||||
+3
-3
@@ -20,7 +20,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
@@ -32,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fixHtmlWhitespace
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
@@ -46,7 +46,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.others.Toggle
|
||||
import com.futo.platformplayer.views.adapters.PreviewPostView
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
@@ -364,7 +364,7 @@ class PostDetailFragment : MainFragment {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
|
||||
+20
-3
@@ -218,6 +218,13 @@ class SourceDetailFragment : MainFragment() {
|
||||
BigButtonGroup(c, context.getString(R.string.authentication),
|
||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||
logoutSource();
|
||||
},
|
||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||
logoutSource(false);
|
||||
}.apply {
|
||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -286,12 +293,22 @@ class SourceDetailFragment : MainFragment() {
|
||||
_sourceButtons.addView(group);
|
||||
}
|
||||
|
||||
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
|
||||
val advancedButtons = BigButtonGroup(c, "Advanced",
|
||||
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
|
||||
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
},
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -311,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
reloadSource(config.id);
|
||||
};
|
||||
}
|
||||
private fun logoutSource() {
|
||||
private fun logoutSource(clear: Boolean = true) {
|
||||
val config = _config ?: return;
|
||||
|
||||
StatePlugins.instance.setPluginAuth(config.id, null);
|
||||
@@ -319,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
|
||||
//TODO: Maybe add a dialog option..
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout) {
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
|
||||
+54
-16
@@ -80,13 +80,23 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (_view?.onBackPressed() == true)
|
||||
return true
|
||||
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
|
||||
|
||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
@@ -107,16 +117,23 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
};
|
||||
|
||||
initializeToolbarContent();
|
||||
|
||||
setPreviewsEnabled(Settings.instance.subscriptions.previewFeedItems);
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
|
||||
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
|
||||
setProgress(currentProgress.first, currentProgress.second);
|
||||
|
||||
if(recyclerData.loadedFeedStyle != feedStyle ||
|
||||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
|
||||
recyclerData.lastLoad = OffsetDateTime.now();
|
||||
loadResults();
|
||||
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
|
||||
loadResults(false);
|
||||
else if(recyclerData.results.size == 0)
|
||||
loadCache();
|
||||
}
|
||||
|
||||
val announcementsView = _announcementsView;
|
||||
@@ -170,12 +187,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
|
||||
if(!_bypassRateLimit) {
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
if(rateLimitPlugins.any())
|
||||
throw RateLimitException(rateLimitPlugins.map { it.key.id });
|
||||
}
|
||||
_bypassRateLimit = false;
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
|
||||
|
||||
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
|
||||
@@ -184,7 +202,15 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
return@TaskHandler resp;
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.success {
|
||||
if(!Settings.instance.subscriptions.alwaysReloadFromCache)
|
||||
loadedResult(it);
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
loadCache();
|
||||
}
|
||||
} //TODO: Remove
|
||||
.exception<RateLimitException> {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
@@ -198,7 +224,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
context.getString(R.string.rate_limit_warning), context.getString(R.string.this_is_a_temporary_measure_to_prevent_people_from_hitting_rate_limit_until_we_have_better_support_for_lots_of_subscriptions) + context.getString(R.string.you_have_too_many_subscriptions_for_the_following_plugins),
|
||||
subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
|
||||
_bypassRateLimit = true;
|
||||
loadResults();
|
||||
loadResults(true);
|
||||
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
|
||||
UIDialogs.Action("OK", {
|
||||
finishRefreshLayoutLoader();
|
||||
@@ -210,7 +236,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
|
||||
if(it !is CancellationException)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
@@ -247,13 +273,16 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
else null;
|
||||
_filterSettings.save();
|
||||
};
|
||||
loadResults(false)
|
||||
if(Settings.instance.subscriptions.fetchOnTabOpen) //TODO: Do this different, temporary workaround
|
||||
loadResults(false);
|
||||
else
|
||||
loadCache();
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
||||
return results.filter {
|
||||
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO) ContentType.MEDIA else it.contentType);
|
||||
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||
|
||||
if(it.datetime?.isAfter(nowSoon) == true) {
|
||||
if(!_filterSettings.allowPlanned)
|
||||
@@ -275,23 +304,32 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
loadResults(true);
|
||||
}
|
||||
|
||||
|
||||
private fun loadCache() {
|
||||
Logger.i(TAG, "Subscriptions load cache");
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscriptions show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
}
|
||||
private fun loadResults(withRefetch: Boolean = false) {
|
||||
setLoading(true);
|
||||
Logger.i(TAG, "Subscriptions load");
|
||||
if(recyclerData.results.size == 0) {
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscription show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
loadCache();
|
||||
} else {
|
||||
setTextCentered(null);
|
||||
}
|
||||
_taskGetPager.run(withRefetch);
|
||||
}
|
||||
|
||||
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
|
||||
super.onRestoreCachedData(cachedData);
|
||||
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
}
|
||||
private fun loadedResult(pager: IPager<IPlatformContent>) {
|
||||
Logger.i(TAG, "Subscriptions new pager loaded");
|
||||
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
+4
-1
@@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(it);
|
||||
} else {
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-3
@@ -68,8 +68,6 @@ class VideoDetailFragment : MainFragment {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
Logger.i(TAG, "onShownWithView parameter=$parameter")
|
||||
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
if(parameter is IPlatformVideoDetails)
|
||||
_viewDetail?.setVideoDetails(parameter, true);
|
||||
else if (parameter is IPlatformVideo)
|
||||
@@ -105,6 +103,8 @@ class VideoDetailFragment : MainFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
|
||||
changeOrientation(OrientationManager.Orientation.PORTRAIT);
|
||||
if(lastOrientation == newOrientation)
|
||||
return;
|
||||
|
||||
@@ -174,7 +174,6 @@ class VideoDetailFragment : MainFragment {
|
||||
_viewDetail?.onStop();
|
||||
close();
|
||||
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
StatePlayer.instance.clearQueue();
|
||||
StatePlayer.instance.setPlayerClosed();
|
||||
}
|
||||
|
||||
+217
-57
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.provider.Browser
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
@@ -38,6 +37,9 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
@@ -50,6 +52,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
@@ -57,6 +60,7 @@ 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
|
||||
@@ -65,11 +69,13 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.views.MonetizationView
|
||||
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
||||
import com.futo.platformplayer.views.casting.CastView
|
||||
import com.futo.platformplayer.views.comments.AddCommentView
|
||||
@@ -79,6 +85,7 @@ import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.overlays.SupportOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.*
|
||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
@@ -95,7 +102,6 @@ import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView
|
||||
import com.google.android.exoplayer2.ui.TimeBar
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
|
||||
import com.google.common.base.Stopwatch
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
@@ -118,6 +124,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private var _searchVideo: IPlatformVideo? = null;
|
||||
var video: IPlatformVideoDetails? = null
|
||||
private set;
|
||||
var videoLocal: VideoLocal? = null;
|
||||
private var _playbackTracker: IPlaybackTracker? = null;
|
||||
|
||||
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
|
||||
@@ -173,6 +180,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _addCommentView: AddCommentView;
|
||||
private val _toggleCommentType: Toggle;
|
||||
|
||||
private val _layoutSkip: LinearLayout;
|
||||
private val _textSkip: TextView;
|
||||
private val _textResume: TextView;
|
||||
private val _layoutResume: LinearLayout;
|
||||
private var _jobHideResume: Job? = null;
|
||||
@@ -190,6 +199,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _container_content_replies: RepliesOverlay;
|
||||
private val _container_content_description: DescriptionOverlay;
|
||||
private val _container_content_liveChat: LiveChatOverlay;
|
||||
private val _container_content_support: SupportOverlay;
|
||||
|
||||
private var _container_content_current: View;
|
||||
|
||||
@@ -199,9 +209,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _imageDislikeIcon: ImageView;
|
||||
private val _imageLikeIcon: ImageView;
|
||||
|
||||
private val _buttonSupport: LinearLayout;
|
||||
private val _buttonStore: LinearLayout;
|
||||
private val _layoutMonetization: LinearLayout;
|
||||
private val _monetization: MonetizationView;
|
||||
|
||||
private val _buttonMore: RoundButton;
|
||||
|
||||
@@ -211,6 +219,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
private var _lastAudioSource: IAudioSource? = null;
|
||||
private var _lastSubtitleSource: ISubtitleSource? = null;
|
||||
private var _isCasting: Boolean = false;
|
||||
|
||||
var isPlaying: Boolean = false
|
||||
private set;
|
||||
var lastPositionMilliseconds: Long = 0
|
||||
private set;
|
||||
private var _historicalPosition: Long = 0;
|
||||
@@ -291,11 +302,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_replies = findViewById(R.id.videodetail_container_replies);
|
||||
_container_content_description = findViewById(R.id.videodetail_container_description);
|
||||
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
|
||||
_container_content_support = findViewById(R.id.videodetail_container_support)
|
||||
|
||||
_textComments = findViewById(R.id.text_comments);
|
||||
_addCommentView = findViewById(R.id.add_comment_view);
|
||||
_commentsList = findViewById(R.id.comments_list);
|
||||
|
||||
_layoutSkip = findViewById(R.id.layout_skip);
|
||||
_textSkip = findViewById(R.id.text_skip);
|
||||
_layoutResume = findViewById(R.id.layout_resume);
|
||||
_textResume = findViewById(R.id.text_resume);
|
||||
_layoutPlayerContainer = findViewById(R.id.layout_player_container);
|
||||
@@ -307,11 +321,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_imageLikeIcon = findViewById(R.id.image_like_icon);
|
||||
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
|
||||
|
||||
_buttonSupport = findViewById(R.id.button_support);
|
||||
_buttonStore = findViewById(R.id.button_store);
|
||||
_layoutMonetization = findViewById(R.id.layout_monetization);
|
||||
|
||||
_layoutMonetization.visibility = View.GONE;
|
||||
_monetization = findViewById(R.id.monetization);
|
||||
_player.attachPlayer();
|
||||
|
||||
|
||||
@@ -324,16 +334,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
_buttonSupport.setOnClickListener {
|
||||
val author = video?.author ?: _searchVideo?.author;
|
||||
author?.let { fragment.navigate<ChannelFragment>(it).selectTab(2); };
|
||||
fragment.lifecycleScope.launch {
|
||||
delay(100);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
_monetization.onSupportTap.subscribe {
|
||||
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile, false);
|
||||
switchContentView(_container_content_support);
|
||||
};
|
||||
|
||||
_buttonStore.setOnClickListener {
|
||||
_monetization.onStoreTap.subscribe {
|
||||
_polycentricProfile?.profile?.systemState?.store?.let {
|
||||
try {
|
||||
val uri = Uri.parse(it);
|
||||
@@ -346,6 +352,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
};
|
||||
|
||||
_player.attachPlayer();
|
||||
|
||||
_container_content_liveChat.onRaidNow.subscribe {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
StateApp.instance.preventPictureInPicture.subscribe(this) {
|
||||
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
|
||||
preventPictureInPicture = true;
|
||||
@@ -403,6 +416,21 @@ class VideoDetailView : ConstraintLayout {
|
||||
_cast.onSettingsClick.subscribe { showVideoSettings() };
|
||||
_player.onVideoSettings.subscribe { showVideoSettings() };
|
||||
_player.onToggleFullScreen.subscribe(::handleFullScreen);
|
||||
_player.onChapterChanged.subscribe { chapter, isScrub ->
|
||||
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
|
||||
_layoutSkip.visibility = GONE;
|
||||
|
||||
if(!isScrub) {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
}
|
||||
else if(chapter?.type == ChapterType.SKIP) {
|
||||
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
||||
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cast.onMinimizeClick.subscribe {
|
||||
_player.setFullScreen(false);
|
||||
onMinimize.emit();
|
||||
@@ -416,6 +444,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!_isCasting && !_didStop) {
|
||||
setLastPositionMilliseconds(position, true);
|
||||
}
|
||||
updatePlaybackTracking(position);
|
||||
};
|
||||
|
||||
_player.onVideoClicked.subscribe {
|
||||
@@ -475,8 +504,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
updatePillButtonVisibilities();
|
||||
|
||||
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
|
||||
if (StateCasting.instance.activeDevice != null) {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
handlePlayChanged(it);
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -520,6 +555,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
|
||||
_description_viewMore.setOnClickListener {
|
||||
switchContentView(_container_content_description);
|
||||
@@ -570,6 +606,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_lastSubtitleSource = null;
|
||||
video = null;
|
||||
_playbackTracker = null;
|
||||
Logger.i(TAG, "Keep screen on unset onClose")
|
||||
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
};
|
||||
|
||||
_layoutResume.setOnClickListener {
|
||||
@@ -581,6 +619,68 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
_layoutSkip.setOnClickListener {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val _trackingUpdateTimeLock = Object();
|
||||
val _trackingUpdateInterval = 2500;
|
||||
var _trackingLastUpdateTime = System.currentTimeMillis();
|
||||
var _trackingLastPosition: Long = 0;
|
||||
var _trackingLastVideo: IPlatformVideoDetails? = null;
|
||||
var _trackingTotalWatched: Long = 0;
|
||||
var _trackingDidCountView: Boolean = false;
|
||||
var _trackingLastVideoSubscription: Subscription? = null;
|
||||
fun updatePlaybackTracking(position: Long) {
|
||||
if(!Settings.instance.subscriptions.allowPlaytimeTracking)
|
||||
return;
|
||||
val now = System.currentTimeMillis();
|
||||
val shouldUpdate = synchronized(_trackingUpdateTimeLock) {
|
||||
val doUpdate = (now - _trackingLastUpdateTime) > _trackingUpdateInterval;
|
||||
if(doUpdate)
|
||||
_trackingLastUpdateTime = now;
|
||||
return@synchronized doUpdate;
|
||||
}
|
||||
if(shouldUpdate) {
|
||||
val currentVideo = video;
|
||||
val delta = position - _trackingLastPosition;
|
||||
_trackingLastPosition = position;
|
||||
|
||||
if(currentVideo != null && currentVideo == _trackingLastVideo) {
|
||||
if(delta > 500 && delta < _trackingUpdateInterval * 1.5) {
|
||||
_trackingLastVideoSubscription?.let {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] watch time delta [${delta}]" +
|
||||
"(${"%.2f".format((_trackingTotalWatched / 1000) / currentVideo.duration.toDouble().coerceAtLeast(1.0))})");
|
||||
it.updatePlayback(currentVideo, (delta / 1000).toInt());
|
||||
_trackingTotalWatched += delta;
|
||||
if(!_trackingDidCountView && currentVideo.duration > 0) {
|
||||
val percentage = (_trackingTotalWatched / 1000) / currentVideo.duration.toDouble();
|
||||
if(percentage > 0.4) {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] new view");
|
||||
_trackingDidCountView = true;
|
||||
it.addPlaybackView();
|
||||
}
|
||||
}
|
||||
it.saveAsync();
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(_trackingLastVideo == null && currentVideo == null)
|
||||
return;
|
||||
_trackingLastVideo = currentVideo;
|
||||
_trackingTotalWatched = 0;
|
||||
if(currentVideo?.author?.url != null)
|
||||
_trackingLastVideoSubscription = StateSubscriptions.instance.getSubscription(currentVideo.author.url);
|
||||
else
|
||||
_trackingLastVideoSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMoreButtons() {
|
||||
@@ -725,7 +825,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
0 -> handlePause();
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false))
|
||||
if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
|
||||
_player.switchToAudioMode();
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
@@ -760,6 +860,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_replies.cleanup();
|
||||
_container_content_queue.cleanup();
|
||||
_container_content_description.cleanup();
|
||||
_container_content_support.cleanup();
|
||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
@@ -944,10 +1045,32 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||
}
|
||||
|
||||
val video = if(videoDetail is VideoLocal)
|
||||
videoDetail;
|
||||
else //TODO: Update cached video if it exists with video
|
||||
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
|
||||
var videoLocal: VideoLocal? = null;
|
||||
var video: IPlatformVideoDetails? = null;
|
||||
|
||||
if(videoDetail is VideoLocal) {
|
||||
videoLocal = videoDetail;
|
||||
video = videoDetail;
|
||||
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
|
||||
videoTask.invokeOnCompletion { ex ->
|
||||
if(ex != null) {
|
||||
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
|
||||
return@invokeOnCompletion;
|
||||
}
|
||||
val result = videoTask.getCompleted();
|
||||
if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) {
|
||||
this.video = result;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateQualitySourcesOverlay(result, videoLocal);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else { //TODO: Update cached video if it exists with video
|
||||
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
|
||||
video = videoDetail;
|
||||
}
|
||||
this.videoLocal = videoLocal;
|
||||
this.video = video;
|
||||
this._playbackTracker = null;
|
||||
|
||||
@@ -961,9 +1084,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to get chapters", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
_player.setChapters(null);
|
||||
|
||||
/*withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
try {
|
||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||
@@ -980,9 +1105,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
me._playbackTracker = tracker;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||
Logger.e(TAG, "Playback tracker failed", ex);
|
||||
if(me.video?.isLive == true) withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
|
||||
};
|
||||
else withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -992,7 +1121,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_player.setMetadata(video.name, video.author.name);
|
||||
|
||||
_toggleCommentType.setValue(false, false);
|
||||
_toggleCommentType.setValue(Settings.instance.comments.defaultCommentSection == 1, false);
|
||||
updateCommentType(true);
|
||||
|
||||
//UI
|
||||
@@ -1006,6 +1135,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
|
||||
}
|
||||
|
||||
|
||||
video.author.let {
|
||||
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
|
||||
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
|
||||
else
|
||||
_monetization.setPlatformMembership(null, null);
|
||||
}
|
||||
|
||||
_minimize_title.text = video.name;
|
||||
_minimize_meta.text = video.author.name;
|
||||
|
||||
@@ -1071,7 +1208,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
@@ -1125,7 +1262,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
|
||||
//Overlay
|
||||
updateQualitySourcesOverlay(video);
|
||||
updateQualitySourcesOverlay(video, videoLocal);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -1376,6 +1513,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
||||
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
||||
_overlay_quality_selector?.show();
|
||||
_slideUpOverlay = _overlay_quality_selector;
|
||||
}
|
||||
|
||||
fun prevVideo() {
|
||||
@@ -1403,9 +1541,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
//Quality Selector data
|
||||
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
||||
val v = video ?: return;
|
||||
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||
updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||
}
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
Logger.i(TAG, "updateQualitySourcesOverlay");
|
||||
|
||||
val video: IPlatformVideoDetails?;
|
||||
@@ -1413,24 +1551,35 @@ class VideoDetailView : ConstraintLayout {
|
||||
val localAudioSource: List<LocalAudioSource>?;
|
||||
val localSubtitleSources: List<LocalSubtitleSource>?;
|
||||
|
||||
val videoSources: List<IVideoSource>?;
|
||||
val audioSources: List<IAudioSource>?;
|
||||
|
||||
if(videoDetails is VideoLocal) {
|
||||
video = videoDetails.videoSerialized;
|
||||
video = videoLocal?.videoSerialized;
|
||||
localVideoSources = videoDetails.videoSource.toList();
|
||||
localAudioSource = videoDetails.audioSource.toList();
|
||||
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
||||
videoSources = null
|
||||
audioSources = null;
|
||||
}
|
||||
else {
|
||||
video = videoDetails;
|
||||
localVideoSources = null;
|
||||
localAudioSource = null;
|
||||
localSubtitleSources = null;
|
||||
videoSources = video?.video?.videoSources?.toList();
|
||||
audioSources = if(video?.video?.isUnMuxed == true)
|
||||
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||
else null
|
||||
if(videoLocal != null) {
|
||||
localVideoSources = videoLocal.videoSource.toList();
|
||||
localAudioSource = videoLocal.audioSource.toList();
|
||||
localSubtitleSources = videoLocal.subtitlesSources.toList();
|
||||
}
|
||||
else {
|
||||
localVideoSources = null;
|
||||
localAudioSource = null;
|
||||
localSubtitleSources = null;
|
||||
}
|
||||
}
|
||||
|
||||
val videoSources = video?.video?.videoSources?.toList();
|
||||
val audioSources = if(video?.video?.isUnMuxed == true)
|
||||
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||
else null
|
||||
|
||||
val bestVideoSources = videoSources?.map { it.height * it.width }
|
||||
?.distinct()
|
||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
@@ -1458,7 +1607,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
if(localVideoSources?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
||||
*localVideoSources.stream()
|
||||
*localVideoSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
|
||||
{ handleSelectVideoTrack(it) });
|
||||
@@ -1466,7 +1615,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else null,
|
||||
if(localAudioSource?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
||||
*localAudioSource.stream()
|
||||
*localAudioSource
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
{ handleSelectAudioTrack(it) });
|
||||
@@ -1482,7 +1631,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else null,
|
||||
if(liveStreamVideoFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
||||
*liveStreamVideoFormats.stream()
|
||||
*liveStreamVideoFormats
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
||||
{ _player.selectVideoTrack(it.height) });
|
||||
@@ -1490,7 +1639,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else null,
|
||||
if(liveStreamAudioFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
||||
*liveStreamAudioFormats.stream()
|
||||
*liveStreamAudioFormats
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
||||
{ _player.selectAudioTrack(it.bitrate) });
|
||||
@@ -1499,7 +1648,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
if(bestVideoSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources.stream()
|
||||
*bestVideoSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
||||
{ handleSelectVideoTrack(it) });
|
||||
@@ -1507,7 +1656,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
*bestAudioSources.stream()
|
||||
*bestAudioSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
{ handleSelectAudioTrack(it) });
|
||||
@@ -1577,14 +1726,28 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(playing) {
|
||||
_minimize_controls_pause.visibility = View.VISIBLE;
|
||||
_minimize_controls_play.visibility = View.GONE;
|
||||
|
||||
if (_isCasting) {
|
||||
if (Settings.instance.casting.keepScreenOn) {
|
||||
Logger.i(TAG, "Keep screen on set handlePlayChanged casting")
|
||||
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Keep screen on set handlePlayChanged player")
|
||||
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
else {
|
||||
_minimize_controls_pause.visibility = View.GONE;
|
||||
_minimize_controls_play.visibility = View.VISIBLE;
|
||||
|
||||
Logger.i(TAG, "Keep screen on unset handlePlayChanged")
|
||||
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
|
||||
isPlaying = playing;
|
||||
onPlayChanged.emit(playing);
|
||||
updateTracker(_player.position, playing, true);
|
||||
updateTracker(lastPositionMilliseconds, playing, true);
|
||||
}
|
||||
|
||||
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
|
||||
@@ -1716,11 +1879,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun setCastEnabled(isCasting: Boolean) {
|
||||
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
||||
|
||||
video?.let { updateQualitySourcesOverlay(it); };
|
||||
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
|
||||
|
||||
_isCasting = isCasting;
|
||||
|
||||
if(isCasting) {
|
||||
setFullscreen(false);
|
||||
_player.stop();
|
||||
_player.hideControls(false);
|
||||
_cast.visibility = View.VISIBLE;
|
||||
@@ -1927,7 +2091,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
|
||||
_lastPositionSaveTime = currentTime;
|
||||
}
|
||||
updateTracker(positionMilliseconds, _player.playing, false);
|
||||
|
||||
updateTracker(positionMilliseconds, isPlaying, false);
|
||||
}
|
||||
|
||||
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
|
||||
@@ -2008,12 +2173,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_creatorThumbnail.setHarborAvailable(profile != null, animate);
|
||||
}
|
||||
|
||||
if (profile != null) {
|
||||
_channelName.text = cachedPolycentricProfile.profile.systemState.username;
|
||||
_layoutMonetization.visibility = View.VISIBLE;
|
||||
} else {
|
||||
_layoutMonetization.visibility = View.GONE;
|
||||
}
|
||||
_monetization.setPolycentricProfile(cachedPolycentricProfile, animate);
|
||||
}
|
||||
|
||||
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.futo.platformplayer.images;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
|
||||
@@ -4,9 +4,12 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.getNowDiffDays
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -43,22 +46,41 @@ class Subscription {
|
||||
var uploadStreamInterval : Int = 0;
|
||||
var uploadPostInterval : Int = 0;
|
||||
|
||||
var playbackSeconds: Int = 0;
|
||||
var playbackViews: Int = 0;
|
||||
|
||||
|
||||
constructor(channel : SerializedChannel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
fun shouldFetchVideos() = true;
|
||||
fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7;
|
||||
fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14;
|
||||
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2;
|
||||
fun shouldFetchVideos() = doFetchVideos &&
|
||||
(lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
|
||||
(lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
|
||||
fun shouldFetchStreams() = doFetchStreams && (lastLiveStream.getNowDiffDays() < 7);
|
||||
fun shouldFetchLiveStreams() = doFetchLive && (lastLiveStream.getNowDiffDays() < 14);
|
||||
fun shouldFetchPosts() = doFetchPosts && (lastPost.getNowDiffDays() < 5);
|
||||
|
||||
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
|
||||
fun save() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
fun saveAsync() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
|
||||
fun updateChannel(channel: IPlatformChannel) {
|
||||
this.channel = SerializedChannel.fromChannel(channel);
|
||||
}
|
||||
|
||||
fun updatePlayback(content: IPlatformContentDetails, seconds: Int) {
|
||||
playbackSeconds += seconds;
|
||||
}
|
||||
fun addPlaybackView() {
|
||||
playbackViews += 1;
|
||||
}
|
||||
|
||||
fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) {
|
||||
val interval: Int;
|
||||
val mostRecent: OffsetDateTime?;
|
||||
@@ -84,30 +106,47 @@ class Subscription {
|
||||
else {
|
||||
interval = 5;
|
||||
mostRecent = null;
|
||||
Logger.i("Subscription", "Subscription [${channel.name}]:${type} no results found");
|
||||
}
|
||||
when(type) {
|
||||
ResultCapabilities.TYPE_VIDEOS -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_MIXED -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
uploadStreamInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastLiveStream = mostRecent;
|
||||
else if(lastLiveStream.year > 3000)
|
||||
lastLiveStream = OffsetDateTime.MIN;
|
||||
lastStreamUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_POSTS -> {
|
||||
uploadPostInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastPost = mostRecent;
|
||||
else if(lastPost.year > 3000)
|
||||
lastPost = OffsetDateTime.MIN;
|
||||
lastPostUpdate = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.futo.platformplayer.parsers
|
||||
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
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")
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA") -> {
|
||||
mediaRenditions.add(parseMediaRendition(client, line, baseUrl))
|
||||
}
|
||||
|
||||
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||
independentSegments = true
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3
|
||||
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 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 segments = mutableListOf<Segment>()
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
when {
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||
?: throw Exception("Invalid segment duration format")
|
||||
currentSegment = MediaSegment(duration = duration)
|
||||
}
|
||||
line == "#EXT-X-DISCONTINUITY" -> {
|
||||
segments.add(DiscontinuitySegment())
|
||||
}
|
||||
line =="#EXT-X-ENDLIST" -> {
|
||||
segments.add(EndListSegment())
|
||||
}
|
||||
else -> {
|
||||
currentSegment?.let {
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
segments.add(it)
|
||||
}
|
||||
currentSegment = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments)
|
||||
}
|
||||
|
||||
private fun resolveUrl(baseUrl: String, url: String): String {
|
||||
val baseUri = URI(baseUrl)
|
||||
val urlUri = URI(url)
|
||||
|
||||
return if (urlUri.isAbsolute) {
|
||||
url
|
||||
} else {
|
||||
val resolvedUri = baseUri.resolve(urlUri)
|
||||
resolvedUri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStreamInfo(content: String): StreamInfo {
|
||||
val attributes = parseAttributes(content)
|
||||
return StreamInfo(
|
||||
bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(),
|
||||
resolution = attributes["RESOLUTION"],
|
||||
codecs = attributes["CODECS"],
|
||||
frameRate = attributes["FRAME-RATE"],
|
||||
videoRange = attributes["VIDEO-RANGE"],
|
||||
audio = attributes["AUDIO"],
|
||||
video = attributes["VIDEO"],
|
||||
subtitles = attributes["SUBTITLES"],
|
||||
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition {
|
||||
val attributes = parseAttributes(line)
|
||||
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
|
||||
return MediaRendition(
|
||||
type = attributes["TYPE"],
|
||||
uri = uri,
|
||||
groupID = attributes["GROUP-ID"],
|
||||
language = attributes["LANGUAGE"],
|
||||
name = attributes["NAME"],
|
||||
isDefault = attributes["DEFAULT"]?.yesNoToBoolean(),
|
||||
isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(),
|
||||
isForced = attributes["FORCED"]?.yesNoToBoolean()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessionData(line: String): SessionData {
|
||||
val attributes = parseAttributes(line)
|
||||
val dataId = attributes["DATA-ID"]!!
|
||||
val value = attributes["VALUE"]!!
|
||||
return SessionData(dataId, value)
|
||||
}
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val attributePairs = content.substringAfter(":").splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in attributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||
val (key, value) = currentPair.toString().split('=')
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder() // Reset for the next attribute
|
||||
} else {
|
||||
currentPair.append(',') // Continue building the current attribute pair
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
return false;
|
||||
|
||||
if (value.contains(','))
|
||||
return true;
|
||||
|
||||
return _quoteList.contains(key)
|
||||
}
|
||||
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
|
||||
attributes.filter { it.second != null }
|
||||
.joinToString(",") {
|
||||
val value = it.second
|
||||
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}"
|
||||
}
|
||||
.let { if (it.isNotEmpty()) stringBuilder.append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
data class SessionData(
|
||||
val dataId: String,
|
||||
val value: String
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-SESSION-DATA:")
|
||||
appendAttributes(this,
|
||||
"DATA-ID" to dataId,
|
||||
"VALUE" to value
|
||||
)
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
data class StreamInfo(
|
||||
val bandwidth: Int?,
|
||||
val resolution: String?,
|
||||
val codecs: String?,
|
||||
val frameRate: String?,
|
||||
val videoRange: String?,
|
||||
val audio: String?,
|
||||
val video: String?,
|
||||
val subtitles: String?,
|
||||
val closedCaptions: String?
|
||||
)
|
||||
|
||||
data class MediaRendition(
|
||||
val type: String?,
|
||||
val uri: String?,
|
||||
val groupID: String?,
|
||||
val language: String?,
|
||||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
appendAttributes(this,
|
||||
"TYPE" to type,
|
||||
"URI" to uri,
|
||||
"GROUP-ID" to groupID,
|
||||
"LANGUAGE" to language,
|
||||
"NAME" to name,
|
||||
"DEFAULT" to isDefault.toYesNo(),
|
||||
"AUTOSELECT" to isAutoSelect.toYesNo(),
|
||||
"FORCED" to isForced.toYesNo()
|
||||
)
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class MasterPlaylist(
|
||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||
val mediaRenditions: List<MediaRendition>,
|
||||
val sessionDataList: List<SessionData>,
|
||||
val independentSegments: Boolean
|
||||
) {
|
||||
fun buildM3U8(): String {
|
||||
val builder = StringBuilder()
|
||||
builder.append("#EXTM3U\n")
|
||||
if (independentSegments) {
|
||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
}
|
||||
|
||||
mediaRenditions.forEach { rendition ->
|
||||
builder.append(rendition.toM3U8Line())
|
||||
}
|
||||
|
||||
variantPlaylistsRefs.forEach { variant ->
|
||||
builder.append(variant.toM3U8Line())
|
||||
}
|
||||
|
||||
sessionDataList.forEach { data ->
|
||||
builder.append(data.toM3U8Line())
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int,
|
||||
val targetDuration: Int,
|
||||
val mediaSequence: Long,
|
||||
val discontinuitySequence: Int,
|
||||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
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")
|
||||
}
|
||||
|
||||
segments.forEach { segment ->
|
||||
append(segment.toM3U8Line())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Segment {
|
||||
abstract fun toM3U8Line(): String
|
||||
}
|
||||
|
||||
data class MediaSegment (
|
||||
val duration: Double,
|
||||
var uri: String = ""
|
||||
) : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXTINF:${duration},\n")
|
||||
append(uri + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
class DiscontinuitySegment : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-DISCONTINUITY\n")
|
||||
}
|
||||
}
|
||||
|
||||
class EndListSegment : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-ENDLIST\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.futo.platformplayer.parsers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.readHttpHeaderBytes
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class HttpResponseParser : AutoCloseable {
|
||||
private val _inputStream: InputStream;
|
||||
|
||||
var head: String = "";
|
||||
var headers: HttpHeaders = HttpHeaders();
|
||||
|
||||
var contentType: String? = null;
|
||||
var transferEncoding: String? = null;
|
||||
var location: String? = null;
|
||||
var contentLength: Long = -1L;
|
||||
|
||||
var statusCode: Int = -1;
|
||||
|
||||
constructor(inputStream: InputStream) {
|
||||
_inputStream = inputStream;
|
||||
|
||||
val headerBytes = inputStream.readHttpHeaderBytes()
|
||||
ByteArrayInputStream(headerBytes).use {
|
||||
val reader = it.bufferedReader(Charsets.UTF_8)
|
||||
head = reader.readLine() ?: throw EmptyRequestException("No head found");
|
||||
|
||||
val statusLineParts = head.split(" ")
|
||||
if (statusLineParts.size < 3) {
|
||||
throw IllegalStateException("Invalid status line")
|
||||
}
|
||||
|
||||
statusCode = statusLineParts[1].toInt()
|
||||
|
||||
while (true) {
|
||||
val line = reader.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"transfer-encoding" -> transferEncoding = headerValue;
|
||||
"location" -> location = headerValue;
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
_inputStream.close();
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "HttpResponse";
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,12 @@ class PolycentricCache {
|
||||
ContentType.USERNAME.value,
|
||||
ContentType.DESCRIPTION.value,
|
||||
ContentType.STORE.value,
|
||||
ContentType.SERVER.value
|
||||
ContentType.SERVER.value,
|
||||
ContentType.STORE_DATA.value,
|
||||
ContentType.PROMOTION_BANNER.value,
|
||||
ContentType.PROMOTION.value,
|
||||
ContentType.MEMBERSHIP_URLS.value,
|
||||
ContentType.DONATION_DESTINATIONS.value
|
||||
)
|
||||
).eventsList.map { e -> SignedEvent.fromProto(e) };
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.futo.platformplayer.receivers
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateNotifications
|
||||
|
||||
|
||||
class PlannedNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
try {
|
||||
Logger.i(TAG, "Planned Notification received");
|
||||
if(!Settings.instance.notifications.plannedContentNotification)
|
||||
return;
|
||||
if(StateApp.instance.contextOrNull == null)
|
||||
StateApp.instance.initializeFiles();
|
||||
|
||||
val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true);
|
||||
if(!notifs.isEmpty() && context != null) {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
val channel = StateNotifications.instance.contentNotifChannel;
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
var i = 0;
|
||||
for (notif in notifs) {
|
||||
StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex);
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "PlannedNotificationReceiver"
|
||||
|
||||
fun getIntent(context: Context): PendingIntent {
|
||||
return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class ExportingService : Service() {
|
||||
{
|
||||
try{
|
||||
notifyExport(currentExport);
|
||||
doExport(currentExport);
|
||||
doExport(applicationContext, currentExport);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
|
||||
@@ -125,13 +125,13 @@ class ExportingService : Service() {
|
||||
stopService(this);
|
||||
}
|
||||
|
||||
private suspend fun doExport(export: VideoExport) {
|
||||
private suspend fun doExport(context: Context, export: VideoExport) {
|
||||
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
|
||||
|
||||
export.changeState(VideoExport.State.EXPORTING);
|
||||
|
||||
var lastNotifyTime: Long = 0L;
|
||||
val file = export.export { progress ->
|
||||
val file = export.export(context) { progress ->
|
||||
export.progress = progress;
|
||||
|
||||
val currentTime = System.currentTimeMillis();
|
||||
@@ -146,7 +146,7 @@ class ExportingService : Service() {
|
||||
notifyExport(export);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
|
||||
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
|
||||
file.share(this@ExportingService);
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user