mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 226d5c1c68 | |||
| 8b36865f5e | |||
| c3be5f6dc5 | |||
| 4c0eceaa8e | |||
| 7f20250951 | |||
| 4d720b1d81 | |||
| a8921a1aba | |||
| edb9eda0a9 | |||
| 3a81676447 | |||
| 03132ff77b | |||
| 49ddecdea4 | |||
| 44ff951ec6 | |||
| 11319e0ec5 | |||
| 100e98a960 | |||
| c6100ede70 | |||
| a2986a72bd | |||
| e0e90c5f74 | |||
| 11992af81b | |||
| 15d771f7fc | |||
| 5ede474253 | |||
| 7922aa6f80 | |||
| 0c1333fa15 | |||
| 53b9ba0368 | |||
| c3a8877796 | |||
| a464ae9df5 | |||
| 0d16dd0006 | |||
| 48a96140a7 | |||
| 603ef8f295 | |||
| ab07288ba0 | |||
| c0bbe5d491 | |||
| b953ff21e7 | |||
| c14378b534 | |||
| 33d3d9a29c | |||
| 7e83793586 | |||
| 6ba9ec8bc2 | |||
| 0b02ab0e2d | |||
| ff531b5e77 | |||
| b3f9de3b83 | |||
| 86bd71b89c | |||
| 2fca7e9a01 | |||
| 2cc873ef60 | |||
| 7a66ce6bcd | |||
| 2730569b6b | |||
| ede5c4409c | |||
| 0dbe398435 | |||
| bcab3bccbc | |||
| 58c9aeb1a2 | |||
| 4702787784 | |||
| 13100dc38d | |||
| 5227041398 | |||
| 8491d4da1a | |||
| 9bea1563ca | |||
| 9e7b936663 | |||
| 19c84475db | |||
| 4164b1a3f8 | |||
| a9dc038190 | |||
| 2825db88a5 | |||
| 363099b303 | |||
| 5e25a5054f | |||
| 2bc6127f6b | |||
| 064824aedf | |||
| 52044edb2e | |||
| fb12073a82 | |||
| 9944842a2f | |||
| 99dc50894c | |||
| 6598dff6df | |||
| 389798457b | |||
| 623c47fa2e | |||
| 19861fe812 | |||
| dd1c04bea1 | |||
| 8e70f1b865 | |||
| f86fb0ee44 | |||
| c333300906 |
+7
-1
@@ -173,6 +173,7 @@ dependencies {
|
||||
|
||||
//HTTP
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.11.0"
|
||||
|
||||
//JSON
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
|
||||
@@ -180,6 +181,7 @@ dependencies {
|
||||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
@@ -204,6 +206,8 @@ dependencies {
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.70'
|
||||
implementation "com.googlecode.plist:dd-plist:1.23"
|
||||
|
||||
//Protobuf
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
|
||||
@@ -220,7 +224,9 @@ dependencies {
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
//Payment
|
||||
implementation 'com.stripe:stripe-android:20.35.1'
|
||||
implementation('com.stripe:stripe-android:20.35.1') {
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15to18'
|
||||
}
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.futo.platformplayer.casting.SRPClient
|
||||
import com.futo.platformplayer.casting.TLV8Item
|
||||
import com.futo.platformplayer.casting.TLV8Tag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import java.math.BigInteger
|
||||
import org.junit.Test
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@OptIn(ExperimentalStdlibApi::class, ExperimentalUnsignedTypes::class)
|
||||
class AirPlay2Test {
|
||||
@Test
|
||||
fun testSRP() {
|
||||
val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" +
|
||||
"020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" +
|
||||
"4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" +
|
||||
"EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" +
|
||||
"98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" +
|
||||
"9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" +
|
||||
"E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
|
||||
"3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" +
|
||||
"A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" +
|
||||
"ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" +
|
||||
"D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" +
|
||||
"08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray())
|
||||
|
||||
val g = BigInteger(1, "05".hexToByteArray())
|
||||
val I = "alice"
|
||||
val p = "password123"
|
||||
val a = BigInteger(1, "60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393".replace(" ", "").hexToByteArray())
|
||||
val A = BigInteger(1, ("FAB6F5D2 615D1E32 3512E799 1CC37443 F487DA60 4CA8C923 0FCB04E5 41DCE628" +
|
||||
"0B27CA46 80B0374F 179DC3BD C7553FE6 2459798C 701AD864 A91390A2 8C93B644" +
|
||||
"ADBF9C00 745B942B 79F9012A 21B9B787 82319D83 A1F83628 66FBD6F4 6BFC0DDB" +
|
||||
"2E1AB6E4 B45A9906 B82E37F0 5D6F97F6 A3EB6E18 2079759C 4F684783 7B62321A" +
|
||||
"C1B4FA68 641FCB4B B98DD697 A0C73641 385F4BAB 25B79358 4CC39FC8 D48D4BD8" +
|
||||
"67A9A3C1 0F8EA121 70268E34 FE3BBE6F F89998D6 0DA2F3E4 283CBEC1 393D52AF" +
|
||||
"724A5723 0C604E9F BCE583D7 613E6BFF D67596AD 121A8707 EEC46944 95703368" +
|
||||
"6A155F64 4D5C5863 B48F61BD BF19A53E AB6DAD0A 186B8C15 2E5F5D8C AD4B0EF8" +
|
||||
"AA4EA500 8834C3CD 342E5E0F 167AD045 92CD8BD2 79639398 EF9E114D FAAAB919" +
|
||||
"E14E8509 89224DDD 98576D79 385D2210 902E9F9B 1F2D86CF A47EE244 635465F7" +
|
||||
"1058421A 0184BE51 DD10CC9D 079E6F16 04E7AA9B 7CF7883C 7D4CE12B 06EBE160" +
|
||||
"81E23F27 A231D184 32D7D1BB 55C28AE2 1FFCF005 F57528D1 5A88881B B3BBB7FE").replace(" ", "").hexToByteArray())
|
||||
val b = BigInteger(1, "E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20".replace(" ", "").hexToByteArray())
|
||||
val B = ("40F57088 A482D4C7 733384FE 0D301FDD CA9080AD 7D4F6FDF 09A01006 C3CB6D56" +
|
||||
"2E41639A E8FA21DE 3B5DBA75 85B27558 9BDB2798 63C56280 7B2B9908 3CD1429C" +
|
||||
"DBE89E25 BFBD7E3C AD3173B2 E3C5A0B1 74DA6D53 91E6A06E 465F037A 40062548" +
|
||||
"39A56BF7 6DA84B1C 94E0AE20 8576156F E5C140A4 BA4FFC9E 38C3B07B 88845FC6" +
|
||||
"F7DDDA93 381FE0CA 6084C4CD 2D336E54 51C464CC B6EC65E7 D16E548A 273E8262" +
|
||||
"84AF2559 B6264274 215960FF F47BDD63 D3AFF064 D6137AF7 69661C9D 4FEE4738" +
|
||||
"2603C88E AA098058 1D077584 61B777E4 356DDA58 35198B51 FEEA308D 70F75450" +
|
||||
"B71675C0 8C7D8302 FD7539DD 1FF2A11C B4258AA7 0D234436 AA42B6A0 615F3F91" +
|
||||
"5D55CC3B 966B2716 B36E4D1A 06CE5E5D 2EA3BEE5 A1270E87 51DA45B6 0B997B0F" +
|
||||
"FDB0F996 2FEE4F03 BEE780BA 0A845B1D 92714217 83AE6601 A61EA2E3 42E4F2E8" +
|
||||
"BC935A40 9EAD19F2 21BD1B74 E2964DD1 9FC845F6 0EFC0933 8B60B6B2 56D8CAC8" +
|
||||
"89CCA306 CC370A0B 18C8B886 E95DA0AF 5235FEF4 393020D2 B7F30569 04759042").replace(" ", "").hexToByteArray()
|
||||
val s = "BEB25379 D1A8581E B5A72767 3A2441EE".replace(" ", "").hexToByteArray()
|
||||
val v = BigInteger(1, ("9B5E0617 01EA7AEB 39CF6E35 19655A85 3CF94C75 CAF2555E F1FAF759 BB79CB47" +
|
||||
"7014E04A 88D68FFC 05323891 D4C205B8 DE81C2F2 03D8FAD1 B24D2C10 9737F1BE" +
|
||||
"BBD71F91 2447C4A0 3C26B9FA D8EDB3E7 80778E30 2529ED1E E138CCFC 36D4BA31" +
|
||||
"3CC48B14 EA8C22A0 186B222E 655F2DF5 603FD75D F76B3B08 FF895006 9ADD03A7" +
|
||||
"54EE4AE8 8587CCE1 BFDE3679 4DBAE459 2B7B904F 442B041C B17AEBAD 1E3AEBE3" +
|
||||
"CBE99DE6 5F4BB1FA 00B0E7AF 06863DB5 3B02254E C66E781E 3B62A821 2C86BEB0" +
|
||||
"D50B5BA6 D0B478D8 C4E9BBCE C2176532 6FBD1405 8D2BBDE2 C33045F0 3873E539" +
|
||||
"48D78B79 4F0790E4 8C36AED6 E880F557 427B2FC0 6DB5E1E2 E1D7E661 AC482D18" +
|
||||
"E528D729 5EF74372 95FF1A72 D4027717 13F16876 DD050AE5 B7AD53CC B90855C9" +
|
||||
"39566483 58ADFD96 6422F524 98732D68 D1D7FBEF 10D78034 AB8DCB6F 0FCF885C" +
|
||||
"C2B2EA2C 3E6AC866 09EA058A 9DA8CC63 531DC915 414DF568 B09482DD AC1954DE" +
|
||||
"C7EB714F 6FF7D44C D5B86F6B D1158109 30637C01 D0F6013B C9740FA2 C633BA89").replace(" ", "").hexToByteArray())
|
||||
val u = BigInteger(1, ("03AE5F3C 3FA9EFF1 A50D7DBB 8D2F60A1 EA66EA71 2D50AE97 6EE34641 A1CD0E51" +
|
||||
"C4683DA3 83E8595D 6CB56A15 D5FBC754 3E07FBDD D316217E 01A391A1 8EF06DFF").replace(" ", "").hexToByteArray())
|
||||
val S = ("F1036FEC D017C823 9C0D5AF7 E0FCF0D4 08B009E3 6411618A 60B23AAB BFC38339" +
|
||||
"72682312 14BAACDC 94CA1C53 F442FB51 C1B027C3 18AE238E 16414D60 D1881B66" +
|
||||
"486ADE10 ED02BA33 D098F6CE 9BCF1BB0 C46CA2C4 7F2F174C 59A9C61E 2560899B" +
|
||||
"83EF6113 1E6FB30B 714F4E43 B735C9FE 6080477C 1B83E409 3E4D456B 9BCA492C" +
|
||||
"F9339D45 BC42E67C E6C02C24 3E49F5DA 42A869EC 855780E8 4207B8A1 EA6501C4" +
|
||||
"78AAC0DF D3D22614 F531A00D 826B7954 AE8B14A9 85A42931 5E6DD366 4CF47181" +
|
||||
"496A9432 9CDE8005 CAE63C2F 9CA4969B FE840019 24037C44 6559BDBB 9DB9D4DD" +
|
||||
"142FBCD7 5EEF2E16 2C843065 D99E8F05 762C4DB7 ABD9DB20 3D41AC85 A58C05BD" +
|
||||
"4E2DBF82 2A934523 D54E0653 D376CE8B 56DCB452 7DDDC1B9 94DC7509 463A7468" +
|
||||
"D7F02B1B EB168571 4CE1DD1E 71808A13 7F788847 B7C6B7BF A1364474 B3B7E894" +
|
||||
"78954F6A 8E68D45B 85A88E4E BFEC1336 8EC0891C 3BC86CF5 00978801 78D86135" +
|
||||
"E7287234 58538858 D715B7B2 47406222 C1019F53 603F0169 52D49710 0858824C").replace(" ", "").hexToByteArray()
|
||||
val K = ("5CBC219D B052138E E1148C71 CD449896 3D682549 CE91CA24 F098468F 06015BEB" +
|
||||
"6AF245C2 093F98C3 651BCA83 AB8CAB2B 580BBF02 184FEFDF 26142F73 DF95AC50").replace(" ", "").hexToByteArray()
|
||||
|
||||
|
||||
val srp = SRPClient(N, g, I, p)
|
||||
val A_computed = srp.srp_user_start_authentication(a)
|
||||
assert(A_computed == A) { "Mismatch in A value" }
|
||||
|
||||
val triple = srp.srp_user_process_challenge_internal(s, B)
|
||||
val u_computed = triple.first
|
||||
val v_computed = triple.second
|
||||
val M_computed = triple.third
|
||||
val S_computed = srp.getS()!!
|
||||
assert(u_computed == u) { "Mismatch in u" }
|
||||
assert(v_computed == v) { "Mismatch in v" }
|
||||
//assert(M_computed.contentEquals(M)) { "Mismatch in M" }
|
||||
assert(S_computed.contentEquals(S)) { "Mismatch in session key S" }
|
||||
|
||||
val K_computed = srp.getSessionKey()!!
|
||||
assert(K_computed.contentEquals(K)) { "Mismatch in derived key K" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeSimpleSmallValue() {
|
||||
val value = byteArrayOf(0x01, 0x02, 0x03, 0x04).toUByteArray()
|
||||
val item = TLV8Item(TLV8Tag.METHOD, value)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item))
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
|
||||
assertEquals(1, decoded.size)
|
||||
assertEquals(item.tag, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(value))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeExactly255BytesNoFragmentation() {
|
||||
val data255 = UByteArray(255) { it.toUByte() }
|
||||
val item255 = TLV8Item(TLV8Tag.IDENTIFIER, data255)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item255))
|
||||
// Expect: 1 byte tag + 1 byte length + 255 bytes data
|
||||
assertEquals(257, encoded.size)
|
||||
assertEquals(TLV8Tag.IDENTIFIER.value.toByte(), encoded[0])
|
||||
assertEquals(0xFF, encoded[1].toInt() and 0xFF)
|
||||
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
assertEquals(1, decoded.size)
|
||||
assertEquals(TLV8Tag.IDENTIFIER, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(data255))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecode256BytesWithFragmentation() {
|
||||
val data256 = UByteArray(256) { it.toUByte() }
|
||||
val item256 = TLV8Item(TLV8Tag.SALT, data256)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item256))
|
||||
// First fragment header: SALT tag + 0xFF length
|
||||
assertEquals(TLV8Tag.SALT.value.toByte(), encoded[0])
|
||||
assertEquals(0xFF, encoded[1].toInt() and 0xFF)
|
||||
|
||||
// Locate last‐fragment header: two bytes before the final data byte
|
||||
val lastFragmentIndex = encoded.size - (1 /*remaining*/ + 2)
|
||||
assertEquals(TLV8Tag.FRAGMENT_LAST.value.toByte(), encoded[lastFragmentIndex])
|
||||
assertEquals(1.toByte(), encoded[lastFragmentIndex + 1])
|
||||
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
assertEquals(1, decoded.size)
|
||||
assertTrue(decoded[0].value.contentEquals(data256))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeMultipleItems() {
|
||||
val v1 = byteArrayOf(0x0A, 0x0B).toUByteArray()
|
||||
val v2 = byteArrayOf(0xFF.toByte(), 0xEE.toByte(), 0xDD.toByte()).toUByteArray()
|
||||
val items = listOf(
|
||||
TLV8Item(TLV8Tag.PROOF, v1),
|
||||
TLV8Item(TLV8Tag.ERROR, v2)
|
||||
)
|
||||
|
||||
val encoded = TLV8Item.encode(items)
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
|
||||
assertEquals(2, decoded.size)
|
||||
assertEquals(TLV8Tag.PROOF, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(v1))
|
||||
assertEquals(TLV8Tag.ERROR, decoded[1].tag)
|
||||
assertTrue(decoded[1].value.contentEquals(v2))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeUnknownTagThrowsIllegalArgumentException() {
|
||||
// Tag 0x10 isn’t defined in TLV8Tag
|
||||
val bogus = byteArrayOf(0x10, 0x00).toUByteArray()
|
||||
TLV8Item.decode(bogus)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeTruncatedLengthByteThrowsIllegalArgumentException() {
|
||||
// Only a tag byte, missing length byte
|
||||
val onlyTag = byteArrayOf(TLV8Tag.STATE.value.toByte()).toUByteArray()
|
||||
TLV8Item.decode(onlyTag)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeTruncatedDataThrowsIllegalArgumentException() {
|
||||
// Declared length = 2, but only 1 data byte follows
|
||||
val arr = buildList {
|
||||
add(TLV8Tag.FLAGS.value.toByte())
|
||||
add(2) // length
|
||||
add(0x5A) // only one byte of data
|
||||
}.toByteArray().toUByteArray()
|
||||
TLV8Item.decode(arr)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/*
|
||||
class SyncServerTests {
|
||||
|
||||
//private val relayHost = "relay.grayjay.app"
|
||||
@@ -335,4 +335,4 @@ class SyncServerTests {
|
||||
|
||||
class AlwaysAuthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
}
|
||||
}*/
|
||||
@@ -13,7 +13,7 @@ import kotlin.random.Random
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/*
|
||||
data class PipeStreams(
|
||||
val initiatorInput: LittleEndianDataInputStream,
|
||||
val initiatorOutput: LittleEndianDataOutputStream,
|
||||
@@ -509,4 +509,4 @@ class Authorized : IAuthorizable {
|
||||
|
||||
class Unauthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean = false
|
||||
}
|
||||
}*/
|
||||
@@ -103,6 +103,12 @@ class UnavailableException extends ScriptException {
|
||||
super("UnavailableException", msg);
|
||||
}
|
||||
}
|
||||
class ReloadRequiredException extends ScriptException {
|
||||
constructor(msg, reloadData) {
|
||||
super("ReloadRequiredException", msg);
|
||||
this.reloadData = reloadData;
|
||||
}
|
||||
}
|
||||
class AgeException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("AgeException", msg);
|
||||
|
||||
@@ -69,4 +69,16 @@ fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a leading zero byte if BigInteger.toByteArray() included it just to indicate a positive sign.
|
||||
* Mirrors C's expectation that BN_bn2bin yields exactly the “minimal” big‐endian representation.
|
||||
*/
|
||||
fun ByteArray.stripLeadingZero(): ByteArray {
|
||||
return if (this.size > 1 && this[0] == 0.toByte()) {
|
||||
this.copyOfRange(1, this.size)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import com.caoccao.javet.values.primitive.*
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
|
||||
//V8
|
||||
@@ -24,6 +26,10 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
|
||||
return handler(this);
|
||||
}
|
||||
|
||||
inline fun V8Value.getSourcePlugin(): V8Plugin? {
|
||||
return V8Plugin.getPluginFromRuntime(this.v8Runtime);
|
||||
}
|
||||
|
||||
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
|
||||
if(this !is T)
|
||||
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
|
||||
@@ -89,7 +95,29 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
|
||||
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
|
||||
}
|
||||
|
||||
inline fun V8Plugin.ensureIsBusy() {
|
||||
this.let {
|
||||
if (!it.isThreadAlreadyBusy()) {
|
||||
//throw IllegalStateException("Tried to access V8Plugin without busy");
|
||||
val stacktrace = Thread.currentThread().stackTrace;
|
||||
Logger.w("Extensions_V8",
|
||||
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
|
||||
", " + stacktrace.drop(4)?.firstOrNull().toString() +
|
||||
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
|
||||
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
inline fun V8Value.ensureIsBusy() {
|
||||
this?.getSourcePlugin()?.let {
|
||||
it.ensureIsBusy();
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
||||
if(false)
|
||||
ensureIsBusy();
|
||||
return when(T::class) {
|
||||
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
||||
Int::class -> {
|
||||
|
||||
@@ -584,6 +584,25 @@ class Settings : FragmentedStorageFileJson() {
|
||||
playbackSpeeds.sort();
|
||||
return playbackSpeeds;
|
||||
}
|
||||
|
||||
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
|
||||
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
|
||||
var holdPlaybackSpeed: Int = 4;
|
||||
|
||||
fun getHoldPlaybackSpeed(): Double {
|
||||
return when(holdPlaybackSpeed) {
|
||||
0 -> 1.0
|
||||
1 -> 1.25
|
||||
2 -> 1.5
|
||||
3 -> 1.75
|
||||
4 -> 2.0
|
||||
5 -> 2.25
|
||||
6 -> 2.5
|
||||
7 -> 2.75
|
||||
8 -> 3.0
|
||||
else -> 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -999,10 +1018,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
|
||||
var playlistAllowDups: Boolean = true;
|
||||
|
||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4)
|
||||
@FormField(R.string.watch_later_add_start, FieldForm.TOGGLE, R.string.watch_later_add_start_description, 4)
|
||||
var watchLaterAddStart: Boolean = true;
|
||||
|
||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5)
|
||||
var polycentricEnabled: Boolean = true;
|
||||
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5)
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||
var polycentricLocalCache: Boolean = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.futo.platformplayer.dialogs.ConnectedCastingDialog
|
||||
import com.futo.platformplayer.dialogs.ImportDialog
|
||||
import com.futo.platformplayer.dialogs.ImportOptionsDialog
|
||||
import com.futo.platformplayer.dialogs.MigrateDialog
|
||||
import com.futo.platformplayer.dialogs.PairingCodeDialog
|
||||
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.ProgressDialog
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
@@ -463,6 +464,14 @@ class UIDialogs {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showPairingCodeDialog(context: Context, onSubmit: (code: String) -> Unit, onCancel: () -> Unit) {
|
||||
val dialog = PairingCodeDialog(context, onSubmit);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) }
|
||||
dialog.setOnCancelListener { onCancel() }
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun toast(context : Context, text : String, long : Boolean = false) {
|
||||
Toast.makeText(context, text, if(long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@@ -1151,6 +1151,8 @@ class UISlideOverlays {
|
||||
call = {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
else
|
||||
UIDialogs.toast(container.context.getString(R.string.already_in_watch_later))
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -14,10 +14,12 @@ import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.ContentType
|
||||
@@ -29,6 +31,9 @@ import com.futo.polycentric.core.toBase64Url
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
import userpackage.Protocol.URLInfo
|
||||
@@ -39,6 +44,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _loader: View
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
@@ -49,24 +55,47 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
setContentView(R.layout.activity_polycentric_backup);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share);
|
||||
_buttonCopy = findViewById(R.id.button_copy);
|
||||
_imageQR = findViewById(R.id.image_qr);
|
||||
_textQR = findViewById(R.id.text_qr);
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
||||
_exportBundle = createExportBundle();
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
|
||||
try {
|
||||
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt();
|
||||
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
|
||||
_imageQR.setImageBitmap(qrCodeBitmap);
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
|
||||
_imageQR.visibility = View.INVISIBLE;
|
||||
_textQR.visibility = View.INVISIBLE;
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
val qr = generateQRCode(bundle, dimension, dimension)
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
_buttonShare.onClick.subscribe {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
@@ -44,6 +45,7 @@ class PlatformID {
|
||||
val NONE = PlatformID("Unknown", null);
|
||||
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
|
||||
value.ensureIsBusy();
|
||||
val contextName = "PlatformID";
|
||||
return PlatformID(
|
||||
value.getOrThrow(config, "platform", contextName),
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -33,6 +34,7 @@ open class PlatformAuthorLink {
|
||||
val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null);
|
||||
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||
value.ensureIsBusy();
|
||||
if(value.has("membershipUrl"))
|
||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ 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.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -20,6 +21,7 @@ class PlatformAuthorMembershipLink: PlatformAuthorLink {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
|
||||
value.ensureIsBusy();
|
||||
val context = "AuthorMembershipLink"
|
||||
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||
value.getOrThrow(config ,"name", context),
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.expectV8Variant
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
@@ -46,6 +47,7 @@ class ResultCapabilities(
|
||||
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities {
|
||||
val contextName = "ResultCapabilities";
|
||||
value.ensureIsBusy();
|
||||
return ResultCapabilities(
|
||||
value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
|
||||
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
|
||||
@@ -69,6 +71,7 @@ class FilterGroup(
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup {
|
||||
value.ensureIsBusy();
|
||||
return FilterGroup(
|
||||
value.getString("name"),
|
||||
value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null)
|
||||
@@ -90,6 +93,7 @@ class FilterCapability(
|
||||
|
||||
companion object {
|
||||
fun fromV8(obj: V8ValueObject): FilterCapability {
|
||||
obj.ensureIsBusy();
|
||||
val value = obj.get("value") as V8Value;
|
||||
return FilterCapability(
|
||||
obj.getString("name"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -31,6 +32,7 @@ class Thumbnails {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
||||
value.ensureIsBusy();
|
||||
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
||||
.toArray()
|
||||
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
@@ -10,6 +11,7 @@ interface IPlatformLiveEvent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
|
||||
obj.ensureIsBusy();
|
||||
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -27,6 +28,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment {
|
||||
obj.ensureIsBusy();
|
||||
|
||||
val contextName = "LiveEventComment"
|
||||
|
||||
val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.live
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -37,6 +38,7 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventDonation"
|
||||
return LiveEventDonation(
|
||||
obj.getOrThrow(config, "name", contextName),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventEmojis: IPlatformLiveEvent {
|
||||
@@ -15,6 +16,7 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventEmojis"
|
||||
return LiveEventEmojis(
|
||||
obj.getOrThrow(config, "emojis", contextName));
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventRaid: IPlatformLiveEvent {
|
||||
@@ -19,6 +20,7 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventRaid"
|
||||
return LiveEventRaid(
|
||||
obj.getOrThrow(config, "targetName", contextName),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventViewCount: IPlatformLiveEvent {
|
||||
@@ -15,6 +16,7 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventViewCount"
|
||||
return LiveEventViewCount(
|
||||
obj.getOrThrow(config, "viewCount", contextName));
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orDefault
|
||||
import com.futo.platformplayer.serializers.IRatingSerializer
|
||||
@@ -13,8 +14,12 @@ interface IRating {
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) };
|
||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orDefault(default) { fromV8(config, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
|
||||
obj.ensureIsBusy();
|
||||
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes {
|
||||
obj.ensureIsBusy();
|
||||
return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,7 @@ class RatingLikes(val likes: Long) : IRating {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes {
|
||||
obj.ensureIsBusy();
|
||||
return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,7 @@ class RatingScaler(val value: Float) : IRating {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler {
|
||||
obj.ensureIsBusy()
|
||||
return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ class DevJSClient : JSClient {
|
||||
|
||||
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
|
||||
client.setReloadData(getReloadData(true));
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
|
||||
@@ -59,9 +59,13 @@ import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Random
|
||||
import kotlin.Exception
|
||||
import kotlin.reflect.full.findAnnotations
|
||||
import kotlin.reflect.jvm.kotlinFunction
|
||||
@@ -83,6 +87,8 @@ open class JSClient : IPlatformClient {
|
||||
private var _channelCapabilities: ResultCapabilities? = null;
|
||||
private var _peekChannelTypes: List<String>? = null;
|
||||
|
||||
private var _usedReloadData: String? = null;
|
||||
|
||||
protected val _script: String;
|
||||
|
||||
private var _initialized: Boolean = false;
|
||||
@@ -98,14 +104,14 @@ open class JSClient : IPlatformClient {
|
||||
override val icon: ImageVariable;
|
||||
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
|
||||
|
||||
private val _busyLock = Object();
|
||||
private var _busyCounter = 0;
|
||||
private var _busyAction = "";
|
||||
val isBusy: Boolean get() = _busyCounter > 0;
|
||||
val isBusy: Boolean get() = _plugin.isBusy;
|
||||
val isBusyAction: String get() {
|
||||
return _busyAction;
|
||||
}
|
||||
|
||||
val declareOnEnable = HashMap<String, String>();
|
||||
|
||||
val settings: HashMap<String, String?> get() = descriptor.settings;
|
||||
|
||||
val flags: Array<String>;
|
||||
@@ -197,6 +203,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||
client.setReloadData(getReloadData(true));
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
@@ -213,14 +220,31 @@ open class JSClient : IPlatformClient {
|
||||
return plugin.httpClientOthers[id];
|
||||
}
|
||||
|
||||
fun setReloadData(data: String?) {
|
||||
if(data == null) {
|
||||
if(declareOnEnable.containsKey("__reloadData"))
|
||||
declareOnEnable.remove("__reloadData");
|
||||
}
|
||||
else
|
||||
declareOnEnable.put("__reloadData", data ?: "");
|
||||
}
|
||||
fun getReloadData(orLast: Boolean): String? {
|
||||
if(declareOnEnable.containsKey("__reloadData"))
|
||||
return declareOnEnable["__reloadData"];
|
||||
else if(orLast)
|
||||
return _usedReloadData;
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
if (_initialized) return
|
||||
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
plugin.start();
|
||||
|
||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||
plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})");
|
||||
|
||||
|
||||
descriptor.appSettings.loadDefaults(descriptor.config);
|
||||
|
||||
_initialized = true;
|
||||
@@ -260,19 +284,28 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
@JSDocs(0, "source.enable()", "Called when the plugin is enabled/started")
|
||||
fun enable() {
|
||||
fun enable() = isBusyWith("enable") {
|
||||
if(!_initialized)
|
||||
initialize();
|
||||
for(toDeclare in declareOnEnable) {
|
||||
plugin.execute("var ${toDeclare.key} = " + Json.encodeToString(toDeclare.value));
|
||||
}
|
||||
plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})");
|
||||
|
||||
if(declareOnEnable.containsKey("__reloadData")) {
|
||||
Logger.i(TAG, "Plugin [${config.name}] enabled with reload data: ${declareOnEnable["__reloadData"]}");
|
||||
_usedReloadData = declareOnEnable["__reloadData"];
|
||||
declareOnEnable.remove("__reloadData");
|
||||
}
|
||||
_enabled = true;
|
||||
}
|
||||
@JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances")
|
||||
fun saveState(): String? {
|
||||
fun saveState(): String? = isBusyWith("saveState") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSaveState)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
val resp = plugin.executeTyped<V8ValueString>("source.saveState()").value;
|
||||
return resp;
|
||||
return@isBusyWith resp;
|
||||
}
|
||||
|
||||
@JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped")
|
||||
@@ -313,8 +346,10 @@ open class JSClient : IPlatformClient {
|
||||
return _searchCapabilities!!;
|
||||
}
|
||||
|
||||
_searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()"));
|
||||
return _searchCapabilities!!;
|
||||
return busy {
|
||||
_searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()"));
|
||||
return@busy _searchCapabilities!!;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getSearchCapabilities", ex);
|
||||
@@ -342,8 +377,10 @@ open class JSClient : IPlatformClient {
|
||||
if (_searchChannelContentsCapabilities != null)
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
|
||||
_searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()"));
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
return busy {
|
||||
_searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()"));
|
||||
return@busy _searchChannelContentsCapabilities!!;
|
||||
}
|
||||
}
|
||||
@JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform")
|
||||
@JSDocsParameter("channelUrl", "Channel url to search")
|
||||
@@ -375,14 +412,14 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
||||
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
||||
override fun isChannelUrl(url: String): Boolean {
|
||||
override fun isChannelUrl(url: String): Boolean = isBusyWith("isChannelUrl") {
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})")
|
||||
return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isChannelUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
|
||||
@@ -400,9 +437,10 @@ open class JSClient : IPlatformClient {
|
||||
if (_channelCapabilities != null) {
|
||||
return _channelCapabilities!!;
|
||||
}
|
||||
|
||||
_channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()"));
|
||||
return _channelCapabilities!!;
|
||||
return busy {
|
||||
_channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()"));
|
||||
return@busy _channelCapabilities!!;
|
||||
};
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getChannelCapabilities", ex);
|
||||
@@ -513,14 +551,14 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform")
|
||||
@JSDocsParameter("url", "A content url (May not be your platform)")
|
||||
override fun isContentDetailsUrl(url: String): Boolean {
|
||||
override fun isContentDetailsUrl(url: String): Boolean = isBusyWith("isContentDetailsUrl") {
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})")
|
||||
return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isContentDetailsUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
|
||||
@@ -552,7 +590,7 @@ open class JSClient : IPlatformClient {
|
||||
Logger.i(TAG, "JSClient.getPlaybackTracker(${url})");
|
||||
val tracker = plugin.executeTyped<V8Value>("source.getPlaybackTracker(${Json.encodeToString(url)})");
|
||||
if(tracker is V8ValueObject)
|
||||
return@isBusyWith JSPlaybackTracker(config, tracker);
|
||||
return@isBusyWith JSPlaybackTracker(this, tracker);
|
||||
else
|
||||
return@isBusyWith null;
|
||||
}
|
||||
@@ -622,17 +660,19 @@ open class JSClient : IPlatformClient {
|
||||
@JSOptional
|
||||
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
|
||||
@JSDocsParameter("url", "Url of playlist")
|
||||
override fun isPlaylistUrl(url: String): Boolean {
|
||||
override fun isPlaylistUrl(url: String): Boolean = isBusyWith("isPlaylistUrl") {
|
||||
if (!capabilities.hasGetPlaylist)
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
return@isBusyWith busy {
|
||||
return@busy plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isPlaylistUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSOptional
|
||||
@@ -734,19 +774,29 @@ open class JSClient : IPlatformClient {
|
||||
return urls;
|
||||
}
|
||||
|
||||
|
||||
private fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
try {
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
_busyAction = actionName;
|
||||
return handle();
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
return _plugin.busy {
|
||||
return@busy handle();
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter--;
|
||||
}
|
||||
fun <T> busyBlockingSuspended(handle: suspend ()->T): T {
|
||||
return _plugin.busy {
|
||||
return@busy runBlocking {
|
||||
return@runBlocking handle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
//val busyId = kotlin.random.Random.nextInt(9999);
|
||||
return busy {
|
||||
try {
|
||||
_busyAction = actionName;
|
||||
return@busy handle();
|
||||
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -4,6 +4,7 @@ import android.net.Uri
|
||||
import com.futo.platformplayer.SignatureProvider
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.serialization.Contextual
|
||||
@@ -168,12 +169,17 @@ class SourcePluginConfig(
|
||||
}
|
||||
|
||||
fun validate(text: String): Boolean {
|
||||
if(scriptPublicKey.isNullOrEmpty())
|
||||
throw IllegalStateException("No public key present");
|
||||
if(scriptSignature.isNullOrEmpty())
|
||||
throw IllegalStateException("No signature present");
|
||||
try {
|
||||
if (scriptPublicKey.isNullOrEmpty())
|
||||
throw IllegalStateException("No public key present");
|
||||
if (scriptSignature.isNullOrEmpty())
|
||||
throw IllegalStateException("No signature present");
|
||||
|
||||
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
|
||||
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to verify due to an unhandled exception", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun isUrlAllowed(url: String): Boolean {
|
||||
@@ -204,6 +210,8 @@ class SourcePluginConfig(
|
||||
obj.sourceUrl = sourceUrl;
|
||||
return obj;
|
||||
}
|
||||
|
||||
private val TAG = "SourcePluginConfig"
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
|
||||
+19
@@ -67,6 +67,25 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
}
|
||||
|
||||
fun resetAuthCookies() {
|
||||
_currentCookieMap.clear();
|
||||
if(!_auth?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in _auth!!.cookieMap!!)
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
if(!_captcha?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in _captcha!!.cookieMap!!) {
|
||||
if(_currentCookieMap.containsKey(domainCookies.key))
|
||||
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
|
||||
else
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
fun clearOtherCookies() {
|
||||
_otherCookieMap.clear();
|
||||
}
|
||||
|
||||
override fun clone(): ManagedHttpClient {
|
||||
val newClient = JSHttpClient(_jsClient, _auth);
|
||||
newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
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.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -13,6 +14,7 @@ interface IJSContent: IPlatformContent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent {
|
||||
obj.ensureIsBusy();
|
||||
val config = plugin.config;
|
||||
val type: Int = obj.getOrThrow(config, "contentType", "ContentItem");
|
||||
val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null);
|
||||
|
||||
+2
@@ -6,12 +6,14 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IJSContentDetails: IPlatformContent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails {
|
||||
obj.ensureIsBusy();
|
||||
val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails");
|
||||
return when(ContentType.fromInt(type)) {
|
||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ class JSLiveEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
override fun nextPage() = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
super.nextPage();
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
+23
-15
@@ -29,7 +29,9 @@ abstract class JSPager<T> : IPager<T> {
|
||||
this.pager = pager;
|
||||
this.config = config;
|
||||
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
plugin.busy {
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
}
|
||||
getResults();
|
||||
}
|
||||
|
||||
@@ -44,11 +46,14 @@ abstract class JSPager<T> : IPager<T> {
|
||||
override fun nextPage() {
|
||||
warnIfMainThread("JSPager.nextPage");
|
||||
|
||||
pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
/*
|
||||
try {
|
||||
}
|
||||
@@ -70,15 +75,18 @@ abstract class JSPager<T> : IPager<T> {
|
||||
return previousResults;
|
||||
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
if(items.v8Runtime.isDead || items.v8Runtime.isClosed)
|
||||
throw IllegalStateException("Runtime closed");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
.toList();
|
||||
_lastResults = newResults;
|
||||
_resultChanged = false;
|
||||
return newResults;
|
||||
|
||||
return plugin.getUnderlyingPlugin().busy {
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
if (items.v8Runtime.isDead || items.v8Runtime.isClosed)
|
||||
throw IllegalStateException("Runtime closed");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
.toList();
|
||||
_lastResults = newResults;
|
||||
_resultChanged = false;
|
||||
return@busy newResults;
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun convertResult(obj: V8ValueObject): T;
|
||||
|
||||
+43
-23
@@ -2,37 +2,50 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
class JSPlaybackTracker: IPlaybackTracker {
|
||||
private val _config: IV8PluginConfig;
|
||||
private val _obj: V8ValueObject;
|
||||
private lateinit var _client: JSClient;
|
||||
private lateinit var _config: IV8PluginConfig;
|
||||
private lateinit var _obj: V8ValueObject;
|
||||
|
||||
private var _hasCalledInit: Boolean = false;
|
||||
private val _hasInit: Boolean;
|
||||
private var _hasInit: Boolean = false;
|
||||
|
||||
private var _lastRequest: Long = Long.MIN_VALUE;
|
||||
|
||||
private val _hasOnConcluded: Boolean;
|
||||
private var _hasOnConcluded: Boolean = false;
|
||||
|
||||
override var nextRequest: Int = 1000
|
||||
private set;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
constructor(client: JSClient, obj: V8ValueObject) {
|
||||
warnIfMainThread("JSPlaybackTracker.constructor");
|
||||
if(!obj.has("onProgress"))
|
||||
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
|
||||
if(!obj.has("nextRequest"))
|
||||
throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker");
|
||||
_hasOnConcluded = obj.has("onConcluded");
|
||||
|
||||
this._config = config;
|
||||
this._obj = obj;
|
||||
this._hasInit = obj.has("onInit");
|
||||
client.busy {
|
||||
if (!obj.has("onProgress"))
|
||||
throw ScriptImplementationException(
|
||||
client.config,
|
||||
"Missing onProgress on PlaybackTracker"
|
||||
);
|
||||
if (!obj.has("nextRequest"))
|
||||
throw ScriptImplementationException(
|
||||
client.config,
|
||||
"Missing nextRequest on PlaybackTracker"
|
||||
);
|
||||
_hasOnConcluded = obj.has("onConcluded");
|
||||
|
||||
this._client = client;
|
||||
this._config = client.config;
|
||||
this._obj = obj;
|
||||
this._hasInit = obj.has("onInit");
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit(seconds: Double) {
|
||||
@@ -40,12 +53,15 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
synchronized(_obj) {
|
||||
if(_hasCalledInit)
|
||||
return;
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeVoid("onInit", seconds);
|
||||
|
||||
_client.busy {
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeVoid("onInit", seconds);
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +71,12 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
if(!_hasCalledInit && _hasInit)
|
||||
onInit(seconds);
|
||||
else {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
_client.busy {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +85,9 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
if(_hasOnConcluded) {
|
||||
synchronized(_obj) {
|
||||
Logger.i("JSPlaybackTracker", "onConcluded");
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
_client.busy {
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-53
@@ -46,16 +46,18 @@ class JSRequestExecutor {
|
||||
if (_executor.isClosed)
|
||||
throw IllegalStateException("Executor object is closed");
|
||||
|
||||
val result = if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
return _plugin.getUnderlyingPlugin().busy {
|
||||
|
||||
val result = if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
@@ -64,34 +66,35 @@ class JSRequestExecutor {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
|
||||
try {
|
||||
if(result is V8ValueString) {
|
||||
val base64Result = Base64.getDecoder().decode(result.value);
|
||||
return base64Result;
|
||||
}
|
||||
if(result is V8ValueTypedArray) {
|
||||
val buffer = result.buffer;
|
||||
val byteBuffer = buffer.byteBuffer;
|
||||
val bytesResult = ByteArray(result.byteLength);
|
||||
byteBuffer.get(bytesResult, 0, result.byteLength);
|
||||
buffer.close();
|
||||
return bytesResult;
|
||||
}
|
||||
if(result is V8ValueObject && result.has("type")) {
|
||||
val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
|
||||
when(type) {
|
||||
//TODO: Buffer type?
|
||||
try {
|
||||
if(result is V8ValueString) {
|
||||
val base64Result = Base64.getDecoder().decode(result.value);
|
||||
return@busy base64Result;
|
||||
}
|
||||
if(result is V8ValueTypedArray) {
|
||||
val buffer = result.buffer;
|
||||
val byteBuffer = buffer.byteBuffer;
|
||||
val bytesResult = ByteArray(result.byteLength);
|
||||
byteBuffer.get(bytesResult, 0, result.byteLength);
|
||||
buffer.close();
|
||||
return@busy bytesResult;
|
||||
}
|
||||
if(result is V8ValueObject && result.has("type")) {
|
||||
val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
|
||||
when(type) {
|
||||
//TODO: Buffer type?
|
||||
}
|
||||
}
|
||||
if(result is V8ValueUndefined) {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
|
||||
throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
|
||||
}
|
||||
throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
|
||||
}
|
||||
if(result is V8ValueUndefined) {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
|
||||
throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
|
||||
finally {
|
||||
result.close();
|
||||
}
|
||||
throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
|
||||
}
|
||||
finally {
|
||||
result.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,24 +102,25 @@ class JSRequestExecutor {
|
||||
open fun cleanup() {
|
||||
if (!hasCleanup || _executor.isClosed)
|
||||
return;
|
||||
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
_plugin.busy {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
|
||||
+15
-10
@@ -16,7 +16,7 @@ class JSRequestModifier: IRequestModifier {
|
||||
private val _plugin: JSClient;
|
||||
private val _config: IV8PluginConfig;
|
||||
private var _modifier: V8ValueObject;
|
||||
override var allowByteSkip: Boolean;
|
||||
override var allowByteSkip: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, modifier: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
@@ -24,10 +24,13 @@ class JSRequestModifier: IRequestModifier {
|
||||
this._config = plugin.config;
|
||||
val config = plugin.config;
|
||||
|
||||
allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true;
|
||||
plugin.busy {
|
||||
allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true;
|
||||
|
||||
if(!modifier.has("modifyRequest"))
|
||||
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
|
||||
}
|
||||
|
||||
if(!modifier.has("modifyRequest"))
|
||||
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
|
||||
}
|
||||
|
||||
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||
@@ -35,13 +38,15 @@ class JSRequestModifier: IRequestModifier {
|
||||
return Request(url, headers);
|
||||
}
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
return _plugin.busy {
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return req;
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return@busy req;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-2
@@ -6,6 +6,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -35,8 +36,11 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
override fun getSubtitles(): String {
|
||||
if(!hasFetch)
|
||||
throw IllegalStateException("This subtitle doesn't support getSubtitles..");
|
||||
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return v8String.value;
|
||||
|
||||
return _obj.getSourcePlugin()?.busy {
|
||||
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return@busy v8String.value;
|
||||
} ?: "";
|
||||
}
|
||||
|
||||
override suspend fun getSubtitlesURI(): Uri? {
|
||||
|
||||
+22
-14
@@ -27,6 +27,7 @@ import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _plugin: JSClient;
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
@@ -48,6 +49,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
_plugin = plugin;
|
||||
val config = plugin.config;
|
||||
description = _content.getOrThrow(config, "description", contextName);
|
||||
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
|
||||
@@ -82,14 +84,16 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
return getPlaybackTrackerJS();
|
||||
}
|
||||
private fun getPlaybackTrackerJS(): IPlaybackTracker? {
|
||||
return V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_pluginConfig, tracker);
|
||||
else
|
||||
return@catchScriptErrors null;
|
||||
};
|
||||
return _plugin.busy {
|
||||
V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_plugin, tracker);
|
||||
else
|
||||
return@catchScriptErrors null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||
@@ -106,8 +110,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
return _plugin.busy {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
@@ -123,10 +129,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return null;
|
||||
return _plugin.busy {
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return@busy null;
|
||||
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -62,21 +62,27 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
if(_plugin is DevJSClient)
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
}
|
||||
|
||||
if(result != null){
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+15
-9
@@ -32,7 +32,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
override val duration: Long;
|
||||
override val priority: Boolean;
|
||||
|
||||
var url: String?;
|
||||
val url: String?;
|
||||
override var manifest: String?;
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
@@ -67,22 +67,28 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
if(_plugin is DevJSClient) {
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
});
|
||||
|
||||
if(result != null){
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
_plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+9
-2
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orNull
|
||||
@@ -38,7 +39,13 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(plugin, obj);
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource {
|
||||
obj.ensureIsBusy();
|
||||
return JSHLSManifestAudioSource(plugin, obj)
|
||||
};
|
||||
}
|
||||
}
|
||||
+36
-15
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.orNull
|
||||
@@ -53,36 +54,39 @@ abstract class JSSource {
|
||||
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
|
||||
}
|
||||
|
||||
fun getRequestModifier(): IRequestModifier? {
|
||||
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
|
||||
if(_requestModifier != null)
|
||||
return AdhocRequestModifier { url, headers ->
|
||||
return@isBusyWith AdhocRequestModifier { url, headers ->
|
||||
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
||||
};
|
||||
|
||||
if (!hasRequestModifier || _obj.isClosed)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
return JSRequestModifier(_plugin, result)
|
||||
return@isBusyWith JSRequestModifier(_plugin, result)
|
||||
}
|
||||
open fun getRequestExecutor(): JSRequestExecutor? {
|
||||
open fun getRequestExecutor(): JSRequestExecutor? = _plugin.isBusyWith("getRequestExecutor") {
|
||||
if (!hasRequestExecutor || _obj.isClosed)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] requesting");
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
|
||||
_obj.invoke("getRequestExecutor", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
Logger.v("JSSource", "Request executor for [${type}] received");
|
||||
|
||||
return JSRequestExecutor(_plugin, result)
|
||||
if (result !is V8ValueObject)
|
||||
return@isBusyWith null;
|
||||
|
||||
return@isBusyWith JSRequestExecutor(_plugin, result)
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): JSClient? {
|
||||
@@ -105,8 +109,12 @@ abstract class JSSource {
|
||||
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
||||
const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource"
|
||||
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orNull { fromV8Video(plugin, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
|
||||
obj.ensureIsBusy()
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
|
||||
@@ -123,13 +131,26 @@ abstract class JSSource {
|
||||
}
|
||||
}
|
||||
fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) };
|
||||
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj);
|
||||
fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj);
|
||||
fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj);
|
||||
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource{
|
||||
obj.ensureIsBusy();
|
||||
return JSDashManifestSource(plugin, obj)
|
||||
};
|
||||
fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource{
|
||||
obj.ensureIsBusy()
|
||||
return JSDashManifestRawSource(plugin, obj);
|
||||
}
|
||||
fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource {
|
||||
obj?.ensureIsBusy();
|
||||
return JSDashManifestRawAudioSource(plugin, obj)
|
||||
};
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource {
|
||||
obj.ensureIsBusy();
|
||||
return JSHLSManifestSource(plugin, obj)
|
||||
};
|
||||
|
||||
fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? {
|
||||
obj.ensureIsBusy();
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
||||
@@ -31,6 +32,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
||||
|
||||
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject) : IVideoSourceDescriptor {
|
||||
obj.ensureIsBusy();
|
||||
val type = obj.getString("plugin_type")
|
||||
return when(type) {
|
||||
TYPE_MUXED -> JSVideoSourceDescriptor(plugin, obj);
|
||||
|
||||
+179
-125
@@ -15,55 +15,60 @@ import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDevice {
|
||||
class AirPlay1CastingDevice : CastingDevice {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = false;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override val canSetVolume: Boolean get() = false
|
||||
override val canSetSpeed: Boolean get() = true
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
var addresses: Array<InetAddress>? = null
|
||||
var port: Int = 0
|
||||
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _scopeIO: CoroutineScope? = null
|
||||
private var _started: Boolean = false
|
||||
private var _sessionId: String? = null
|
||||
private val _client = ManagedHttpClient()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
this.name = name
|
||||
this.addresses = addresses
|
||||
this.port = port
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
this.name = deviceInfo.name
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray()
|
||||
this.port = deviceInfo.port
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
return addresses?.toList() ?: listOf()
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)")
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "loadContent called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
setTime(resumePosition)
|
||||
setDuration(duration)
|
||||
if (resumePosition > 0.0) {
|
||||
val pos = resumePosition / duration;
|
||||
val pos = resumePosition / duration
|
||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos")
|
||||
} else {
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0")
|
||||
}
|
||||
|
||||
if (speed != null) {
|
||||
@@ -72,117 +77,157 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
throw NotImplementedError();
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}");
|
||||
Logger.i(TAG, "seekVideo()-> $timeSeconds")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "seekVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}")
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
post("rate?value=1.000000");
|
||||
Logger.i(TAG, "resumeVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "resumeVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = true
|
||||
post("rate?value=1.000000")
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = false;
|
||||
post("rate?value=0.000000");
|
||||
Logger.i(TAG, "pauseVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "pauseVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = false
|
||||
post("rate?value=0.000000")
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("stop");
|
||||
Logger.i(TAG, "stopVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "stopVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("stop")
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("stop");
|
||||
stop();
|
||||
Logger.i(TAG, "stopCasting()")
|
||||
if (_sessionId != null) {
|
||||
post("stop")
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
val adrs = addresses ?: return
|
||||
if (_started) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
_started = true;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
_started = true
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
Logger.i(TAG, "Starting...")
|
||||
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port)
|
||||
if (connectedSocket == null) {
|
||||
delay(1000);
|
||||
continue;
|
||||
Logger.i(TAG, "Unable to connect yet; retrying in 1s.")
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
_sessionId = UUID.randomUUID().toString();
|
||||
break;
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
_sessionId = UUID.randomUUID().toString()
|
||||
|
||||
val probeSuccess = get("server-info") != null
|
||||
connectedSocket.close()
|
||||
|
||||
if (!probeSuccess) {
|
||||
Logger.w(TAG, "Handshake (GET /server-info) failed; retrying")
|
||||
_sessionId = null
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Handshake successful. SessionId=$_sessionId")
|
||||
break
|
||||
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val progressInfo = getProgress();
|
||||
val progressInfo = getProgress()
|
||||
if (progressInfo == null) {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
||||
delay(1000);
|
||||
continue;
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.")
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ")
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue
|
||||
setTime(progress)
|
||||
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ")
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
||||
setDuration(duration);
|
||||
delay(1000);
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue
|
||||
setDuration(duration)
|
||||
delay(1000)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
||||
}
|
||||
@@ -190,103 +235,111 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
Logger.i(TAG, "Started.")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
Logger.i(TAG, "Stopping...")
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = null;
|
||||
_sessionId = null
|
||||
usedRemoteAddress = null
|
||||
localAddress = null
|
||||
_started = false
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = null
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "changeSpeed called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
setSpeed(speed)
|
||||
post("rate?value=$speed")
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port)
|
||||
}
|
||||
|
||||
private fun getProgress(): String? {
|
||||
val info = get("scrub");
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
||||
return info;
|
||||
val info = get("scrub")
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}")
|
||||
return info
|
||||
}
|
||||
|
||||
private fun getPlaybackInfo(): String? {
|
||||
val playbackInfo = get("playback-info");
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
||||
return playbackInfo;
|
||||
val playbackInfo = get("playback-info")
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}")
|
||||
return playbackInfo
|
||||
}
|
||||
|
||||
private fun getServerInfo(): String? {
|
||||
val serverInfo = get("server-info");
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
||||
return serverInfo;
|
||||
val serverInfo = get("server-info")
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}")
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
private fun post(path: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
val sessionId = _sessionId ?: return false
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"Content-Length" to "0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "POST $url");
|
||||
val response = _client.post(url, headers);
|
||||
Logger.i(TAG, "POST $url")
|
||||
val response = _client.post(url, headers)
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path");
|
||||
return false;
|
||||
Logger.w(TAG, "Failed to POST $path")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
val sessionId = _sessionId ?: return false
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId,
|
||||
"Content-Type" to contentType
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "POST $url:\n$body");
|
||||
val response = _client.post(url, body, headers);
|
||||
Logger.i(TAG, "POST $url:\n$body")
|
||||
val response = _client.post(url, body, headers)
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path $body");
|
||||
return false;
|
||||
Logger.w(TAG, "Failed to POST $path $body")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(path: String): String? {
|
||||
val sessionId = _sessionId ?: return null;
|
||||
val sessionId = _sessionId ?: return null
|
||||
|
||||
try {
|
||||
val headers = hashMapOf(
|
||||
@@ -294,37 +347,38 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
"Content-Length" to "0",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "GET $url");
|
||||
val response = _client.get(url, headers);
|
||||
Logger.i(TAG, "GET $url")
|
||||
val response = _client.get(url, headers)
|
||||
if (!response.isOk) {
|
||||
return null;
|
||||
Logger.w(TAG, "GET /$path failed (HTTP ${response.code})")
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return response.body.string();
|
||||
return response.body.string()
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to GET $path");
|
||||
return null;
|
||||
Logger.w(TAG, "Failed to GET $path")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
_scopeIO?.launch { action() }
|
||||
return true
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "AirPlayCastingDevice";
|
||||
val TAG = "AirPlay1CastingDevice"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.dd.plist.NSDictionary
|
||||
import com.dd.plist.NSNumber
|
||||
import com.dd.plist.NSString
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.stripLeadingZero
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.bouncycastle.crypto.digests.SHA512Digest
|
||||
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
|
||||
import org.bouncycastle.crypto.params.*
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
class AirPlay2CastingDevice : CastingDevice {
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY2
|
||||
override val isReady: Boolean get() = name != null && addresses?.isNotEmpty() == true && port != 0
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override val canSetVolume: Boolean get() = true
|
||||
override val canSetSpeed: Boolean get() = true
|
||||
|
||||
var addresses: Array<InetAddress>? = null
|
||||
var port: Int = 0
|
||||
|
||||
private val _pairingDataHandler: IPairingDataHandler
|
||||
private var _scopeIO: CoroutineScope? = null
|
||||
private var _started: Boolean = false
|
||||
@Volatile private var _paired: Boolean = false
|
||||
private var _state: AirPlaySenderState = AirPlaySenderState.NOT_CONNECTED
|
||||
private var _srpClient: SRPClient? = null
|
||||
private var _pin: String? = null
|
||||
private var _sessionKey: ByteArray? = null
|
||||
private var _devicePrivateKey: ByteArray? = null
|
||||
private var _devicePublicKey: ByteArray? = null
|
||||
private var _verifierPrivateKey: ByteArray? = null
|
||||
private var _verifierPublicKey: ByteArray? = null
|
||||
private var _accessoryLtpk: ByteArray? = null
|
||||
private var _accessoryCurvePublic: ByteArray? = null
|
||||
private var _accessorySharedKey: ByteArray? = null
|
||||
private var _isEncrypted: Boolean = false
|
||||
private var _outgoingKey: ByteArray? = null
|
||||
private var _incomingKey: ByteArray? = null
|
||||
private var _outCount: Int = 0
|
||||
private var _inCount: Int = 0
|
||||
private var _cseq = 0
|
||||
private val _httpClient: OkHttpClient = OkHttpClient.Builder().cookieJar(JavaNetCookieJar(CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) })).build()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPlay2CastingDevice"
|
||||
private const val DEVICE_ID = "C9635ED0964902E0"
|
||||
private const val CONTENT_TYPE = "application/octet-stream"
|
||||
private const val TAG_LENGTH = 16
|
||||
private const val MAX_BLOCK_LENGTH = 0x400
|
||||
val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" +
|
||||
"020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" +
|
||||
"4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" +
|
||||
"EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" +
|
||||
"98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" +
|
||||
"9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" +
|
||||
"E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
|
||||
"3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" +
|
||||
"A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" +
|
||||
"ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" +
|
||||
"D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" +
|
||||
"08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray())
|
||||
val g = BigInteger(1, "05".hexToByteArray())
|
||||
}
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int, pairingDataHandler: IPairingDataHandler) {
|
||||
this.name = name
|
||||
this.addresses = addresses
|
||||
this.port = port
|
||||
_pairingDataHandler = pairingDataHandler
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo, pairingDataHandler: IPairingDataHandler) {
|
||||
this.name = deviceInfo.name
|
||||
this.addresses = deviceInfo.addresses.mapNotNull { it.toInetAddress() }.toTypedArray()
|
||||
this.port = deviceInfo.port
|
||||
_pairingDataHandler = pairingDataHandler
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = addresses?.toList() ?: emptyList()
|
||||
|
||||
override fun providePairingPin(pin: String?) {
|
||||
Logger.i(TAG, "Pairing PIN provided $pin")
|
||||
_pin = pin
|
||||
_scopeIO?.launch(Dispatchers.IO) {
|
||||
performPair(pin)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) return
|
||||
val adrs = addresses ?: return
|
||||
|
||||
_started = true
|
||||
_paired = false
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
Logger.i(TAG, "Starting AirPlay2 device...")
|
||||
|
||||
_scopeIO?.launch(Dispatchers.IO) {
|
||||
usedRemoteAddress = adrs.firstOrNull { addr ->
|
||||
try {
|
||||
val socket = java.net.Socket(addr, port)
|
||||
localAddress = socket.localAddress
|
||||
socket.close()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed connecting to $addr:$port", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (usedRemoteAddress == null) {
|
||||
Logger.w(TAG, "Could not connect to any address.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Connected to ${usedRemoteAddress}:${port}")
|
||||
if (!_paired) {
|
||||
performPairSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping AirPlay2 device...")
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
_paired = false
|
||||
_started = false
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = null
|
||||
Logger.i(TAG, "AirPlay2 device stopped.")
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
) {
|
||||
Logger.i(TAG, "loadVideo: contentId=$contentId, resumePosition=$resumePosition")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
Logger.i(TAG, "seekVideo: $timeSeconds")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
Logger.i(TAG, "resumeVideo")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
Logger.i(TAG, "pauseVideo")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
Logger.i(TAG, "stopVideo")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
stopVideo()
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
Logger.i(TAG, "changeVolume: $volume")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
Logger.i(TAG, "changeSpeed: $speed")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(
|
||||
name!!,
|
||||
CastProtocolType.AIRPLAY2,
|
||||
addresses!!.mapNotNull { it.hostAddress }.toTypedArray(),
|
||||
port
|
||||
)
|
||||
}
|
||||
|
||||
private fun getUrl(endpoint: String): String {
|
||||
return "http://${usedRemoteAddress?.hostAddress}:$port$endpoint"
|
||||
}
|
||||
|
||||
private fun performPairSetup() {
|
||||
/*Logger.i(TAG, "Starting pair-setup...")
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_PIN_START
|
||||
val pinResult = postHttp("/pair-pin-start", ByteArray(0), null)
|
||||
if (pinResult == true) {
|
||||
Logger.i(TAG, "Waiting for PIN...")
|
||||
onPairingPinRequired.emit()
|
||||
} else {
|
||||
Logger.w(TAG, "Failed to show PIN, attempting pair without PIN")
|
||||
_scopeIO?.launch(Dispatchers.IO) { performPair(null) }
|
||||
}*/
|
||||
|
||||
_scopeIO?.launch(Dispatchers.IO) { performPair(null) }
|
||||
}
|
||||
|
||||
private fun performPair(pin: String?) {
|
||||
Logger.i(TAG, "Performing pair with PIN $pin")
|
||||
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP1
|
||||
val username = "Pair-Setup"
|
||||
val password = pin ?: "3939"
|
||||
_srpClient = SRPClient(N, g, username, password)
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value))
|
||||
val methodItem = TLV8Item(TLV8Tag.METHOD, ubyteArrayOf(PairingMethod.PAIR_SETUP.value))
|
||||
val tlvItems = listOf(stateItem, methodItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", encodedTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to initiate pair-setup")
|
||||
}
|
||||
}
|
||||
|
||||
private fun continuePairSetup(responseData: ByteArray) {
|
||||
if (responseData.isEmpty()) {
|
||||
pairingDidFail("Server response data is empty")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Response: " + TLV8Item.decodeAsString(responseData.asUByteArray()))
|
||||
|
||||
val fields = TLV8Item.decodeAndReassembleWithLogging(responseData.asUByteArray())
|
||||
val errorBytes = fields[TLV8Tag.ERROR]
|
||||
if (errorBytes?.isNotEmpty() == true) {
|
||||
val errorCode = errorBytes[0].toUByte().toInt()
|
||||
if (errorCode == 0x03) {
|
||||
val backoffBytes = fields[TLV8Tag.RETRY_DELAY]
|
||||
val backoffSeconds = ByteBuffer.wrap(backoffBytes).order(ByteOrder.LITTLE_ENDIAN).short
|
||||
pairingDidFail("Pairing backoff requested, should retry in ${backoffSeconds}s")
|
||||
} else {
|
||||
pairingDidFail("Pairing failed with error code $errorCode")
|
||||
}
|
||||
return
|
||||
}
|
||||
val stateBytes = fields[TLV8Tag.STATE]
|
||||
if (stateBytes == null || stateBytes.isEmpty()) {
|
||||
pairingDidFail("State item is missing")
|
||||
return
|
||||
}
|
||||
val remoteState = stateBytes[0].toUByte()
|
||||
Logger.i(TAG, "Transitioned to state ${remoteState}")
|
||||
|
||||
when {
|
||||
// ───── SETUP PHASE ─────
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP1 && remoteState == PairingState.M2.value -> pairSetupM2M3(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP2 && remoteState == PairingState.M4.value -> pairSetupM4M5(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP3 && remoteState == PairingState.M6.value -> pairVerifyM1(fields)
|
||||
|
||||
// ───── VERIFY PHASE ─────
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY1 && remoteState == PairingState.M2.value -> pairVerifyM2(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY2 && remoteState == PairingState.M4.value -> {
|
||||
_isEncrypted = true
|
||||
setCiphers()
|
||||
_state = AirPlaySenderState.READY_TO_PLAY
|
||||
_paired = true
|
||||
pairingDidFinish()
|
||||
}
|
||||
|
||||
else -> pairingDidFail("Unexpected STATE=$remoteState when in $_state")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun pairSetupM2M3(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP2
|
||||
|
||||
val saltBytes = fields[TLV8Tag.SALT]
|
||||
val BBytes = fields[TLV8Tag.PUBLIC_KEY]
|
||||
if (saltBytes == null || BBytes == null) {
|
||||
pairingDidFail("Salt or public key is missing")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized")
|
||||
val ABytes = client.srp_user_start_authentication()
|
||||
|
||||
val M1Bytes = client.srp_user_process_challenge(saltBytes, BBytes)
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value))
|
||||
val aBytes = ABytes.toByteArray().stripLeadingZero().asUByteArray()
|
||||
val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, aBytes)
|
||||
val m1Bytes = M1Bytes.asUByteArray()
|
||||
val proofItem = TLV8Item(TLV8Tag.PROOF, m1Bytes)
|
||||
val tlvItems = listOf(stateItem, pkItem, proofItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", encodedTlv, headers)
|
||||
if (response == null) {
|
||||
pairingDidFail("M2→M3: no HTTP response (connection error)")
|
||||
return
|
||||
}
|
||||
|
||||
val code = response.code
|
||||
val bodyBytes = response.body?.bytes()
|
||||
if (response.isSuccessful && bodyBytes != null) {
|
||||
continuePairSetup(bodyBytes)
|
||||
} else {
|
||||
pairingDidFail("M2→M3 failed: HTTP $code, body=${bodyBytes?.toHexString()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("SRP calculation failed.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairSetupM4M5(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP3
|
||||
|
||||
val proofBytes = fields[TLV8Tag.PROOF]
|
||||
if (proofBytes == null) {
|
||||
pairingDidFail("Proof is missing")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized")
|
||||
val verified = client.srp_user_verify_session(proofBytes)
|
||||
if (!verified) {
|
||||
pairingDidFail("Server authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
val K = client.getSessionKey() ?: throw IllegalStateException("Session key not computed")
|
||||
_sessionKey = K
|
||||
|
||||
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val edPriv = Ed25519PrivateKeyParameters(seed, 0)
|
||||
val edPub = edPriv.generatePublicKey()
|
||||
|
||||
_devicePrivateKey = seed
|
||||
_devicePublicKey = edPub.encoded
|
||||
|
||||
val deviceX = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Controller-Sign-Salt".toByteArray(Charsets.US_ASCII),
|
||||
"Pair-Setup-Controller-Sign-Info".toByteArray(Charsets.US_ASCII),
|
||||
32
|
||||
)
|
||||
|
||||
val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.US_ASCII)
|
||||
val deviceInfo = concat(deviceX, deviceIDBytes, edPub.encoded)
|
||||
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, edPriv)
|
||||
signer.update(deviceInfo, 0, deviceInfo.size)
|
||||
val signature = signer.generateSignature()
|
||||
|
||||
val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray())
|
||||
val publicKeyItem = TLV8Item(TLV8Tag.PUBLIC_KEY, edPub.encoded.asUByteArray())
|
||||
val sigItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray())
|
||||
val tlvItems = listOf(identifierItem, publicKeyItem, sigItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val sessionKey2 = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Encrypt-Salt".toByteArray(Charsets.US_ASCII),
|
||||
"Pair-Setup-Encrypt-Info".toByteArray(Charsets.US_ASCII),
|
||||
32
|
||||
)
|
||||
|
||||
val bcNonce = ByteArray(4) { 0x00 } + "PS-Msg05".toByteArray(Charsets.UTF_8)
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(
|
||||
sessionKey2,
|
||||
bcNonce,
|
||||
ByteArray(0),
|
||||
encodedTlv
|
||||
)
|
||||
val encryptedData = ciphertext + mac
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M5.value))
|
||||
val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray())
|
||||
val responseItems = listOf(stateItem, encryptedDataItem)
|
||||
val responseTlv = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to responseTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", responseTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process M4→M5")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Error in M4→M5.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairVerifyM1(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY1
|
||||
|
||||
val encryptedField = fields[TLV8Tag.ENCRYPTED_DATA]
|
||||
if (encryptedField == null) {
|
||||
pairingDidFail("Encrypted data missing")
|
||||
return
|
||||
}
|
||||
val encryptedTlvData = encryptedField.copyOfRange(0, encryptedField.size - TAG_LENGTH)
|
||||
val tagData = encryptedField.copyOfRange(encryptedField.size - TAG_LENGTH, encryptedField.size)
|
||||
|
||||
try {
|
||||
val K = _sessionKey ?: throw IllegalStateException("No valid session key")
|
||||
val sessionKey2 = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Encrypt-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Setup-Encrypt-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val nonce = ByteArray(4) { 0x00 } + "PS-Msg06".toByteArray(Charsets.UTF_8)
|
||||
val decryptedTlv = chacha20Poly1305Decrypt(
|
||||
sessionKey2,
|
||||
nonce,
|
||||
ByteArray(0),
|
||||
encryptedTlvData,
|
||||
tagData
|
||||
) ?: throw IllegalStateException("Decryption failed")
|
||||
|
||||
val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray())
|
||||
val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray()
|
||||
val accessoryLtpkBytes = accessoryItems.find { it.tag == TLV8Tag.PUBLIC_KEY }?.value?.asByteArray()
|
||||
val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray()
|
||||
|
||||
if (accessoryIdBytes == null || accessoryLtpkBytes == null || accessorySigBytes == null) {
|
||||
pairingDidFail("Accessory data incomplete")
|
||||
return
|
||||
}
|
||||
_accessoryLtpk = accessoryLtpkBytes
|
||||
val accessoryX = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Accessory-Sign-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Setup-Accessory-Sign-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val accessoryInfo = concat(accessoryX, accessoryIdBytes, accessoryLtpkBytes)
|
||||
val verifier = Ed25519Signer()
|
||||
val pubParam = Ed25519PublicKeyParameters(accessoryLtpkBytes, 0)
|
||||
verifier.init(false, pubParam)
|
||||
verifier.update(accessoryInfo, 0, accessoryInfo.size)
|
||||
if (!verifier.verifySignature(accessorySigBytes)) {
|
||||
pairingDidFail("Accessory signature not verified")
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "Accessory signature is valid!")
|
||||
|
||||
val curvePriv = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val curvePub = X25519PrivateKeyParameters(curvePriv, 0)
|
||||
.generatePublicKey()
|
||||
.encoded
|
||||
_verifierPrivateKey = curvePriv
|
||||
_verifierPublicKey = curvePub
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value))
|
||||
val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, curvePub.asUByteArray())
|
||||
val responseItems = listOf(stateItem, pkItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-verify", encodedTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process pair-verify M1")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Pair-verify M1 failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun pairVerifyM2(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY2
|
||||
|
||||
val accessoryCurvePubBytes = fields[TLV8Tag.PUBLIC_KEY]
|
||||
val accessoryEncryptedField = fields[TLV8Tag.ENCRYPTED_DATA]
|
||||
if (accessoryCurvePubBytes == null || accessoryEncryptedField == null) {
|
||||
pairingDidFail("Public key or encrypted data missing")
|
||||
return
|
||||
}
|
||||
_accessoryCurvePublic = accessoryCurvePubBytes
|
||||
|
||||
val encryptedTlvData = accessoryEncryptedField.copyOfRange(0, accessoryEncryptedField.size - TAG_LENGTH)
|
||||
val tagData = accessoryEncryptedField.copyOfRange(accessoryEncryptedField.size - TAG_LENGTH, accessoryEncryptedField.size)
|
||||
|
||||
try {
|
||||
val privParam = X25519PrivateKeyParameters(_verifierPrivateKey!!, 0)
|
||||
val pubParam = X25519PublicKeyParameters(accessoryCurvePubBytes, 0)
|
||||
val sharedSecret = ByteArray(32)
|
||||
privParam.generateSecret(pubParam, sharedSecret, 0)
|
||||
_accessorySharedKey = sharedSecret
|
||||
|
||||
val sessionKey = hkdfExtractExpand(
|
||||
sharedSecret,
|
||||
"Pair-Verify-Encrypt-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Verify-Encrypt-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val nonce = ByteArray(4) { 0x00 } + "PV-Msg02".toByteArray(Charsets.UTF_8)
|
||||
val decryptedTlv = chacha20Poly1305Decrypt(
|
||||
sessionKey,
|
||||
nonce,
|
||||
ByteArray(0),
|
||||
encryptedTlvData,
|
||||
tagData
|
||||
) ?: throw IllegalStateException("Decryption failed")
|
||||
|
||||
val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray())
|
||||
val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray()
|
||||
val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray()
|
||||
if (accessoryIdBytes == null || accessorySigBytes == null) {
|
||||
pairingDidFail("Accessory data incomplete")
|
||||
return
|
||||
}
|
||||
|
||||
val accessoryInfo = concat(
|
||||
accessoryCurvePubBytes,
|
||||
accessoryIdBytes,
|
||||
_verifierPublicKey!!
|
||||
)
|
||||
val verifier = Ed25519Signer()
|
||||
verifier.init(false, Ed25519PublicKeyParameters(_accessoryLtpk!!, 0))
|
||||
verifier.update(accessoryInfo, 0, accessoryInfo.size)
|
||||
if (!verifier.verifySignature(accessorySigBytes)) {
|
||||
pairingDidFail("Accessory signature not verified")
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "Accessory signature is valid!")
|
||||
|
||||
val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.UTF_8)
|
||||
val deviceInfo = concat(
|
||||
_verifierPublicKey!!,
|
||||
deviceIDBytes,
|
||||
accessoryCurvePubBytes
|
||||
)
|
||||
val signer = Ed25519Signer()
|
||||
val edPriv = Ed25519PrivateKeyParameters(_devicePrivateKey!!, 0)
|
||||
signer.init(true, edPriv)
|
||||
signer.update(deviceInfo, 0, deviceInfo.size)
|
||||
val signature = signer.generateSignature()
|
||||
|
||||
val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray())
|
||||
val signatureItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray())
|
||||
val tlvItems = listOf(identifierItem, signatureItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val nonce2 = ByteArray(4) { 0x00 } + "PV-Msg03".toByteArray(Charsets.UTF_8)
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(
|
||||
sessionKey,
|
||||
nonce2,
|
||||
ByteArray(0),
|
||||
encodedTlv
|
||||
)
|
||||
val encryptedData = ciphertext + mac
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value))
|
||||
val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray())
|
||||
val responseItems = listOf(stateItem, encryptedDataItem)
|
||||
val encodedResponse = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedResponse.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-verify", encodedResponse, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process pair-verify M2")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Pair-verify M2 failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCiphers() {
|
||||
val sharedKey = _accessorySharedKey ?: return
|
||||
val prk = hkdfExtractExpand(sharedKey, "Control-Salt".encodeToByteArray(), null, 64)
|
||||
_outgoingKey = hkdfExtractExpand(prk, "Control-Write-Encryption-Key".encodeToByteArray(), null, 32)
|
||||
_incomingKey = hkdfExtractExpand(prk, "Control-Read-Encryption-Key".encodeToByteArray(), null, 32)
|
||||
}
|
||||
|
||||
/*private fun postEncrypted(
|
||||
path: String,
|
||||
plaintext: ByteArray
|
||||
): Boolean {
|
||||
val encrypted = encryptData(plaintext)
|
||||
val req = Request.Builder()
|
||||
.url(getUrl(path))
|
||||
.post(encrypted.toRequestBody(CONTENT_TYPE.toMediaType()))
|
||||
.headers(
|
||||
Headers.headersOf(
|
||||
"User-Agent" to "AirPlay/381.13",
|
||||
"X-Apple-HKP" to "3",
|
||||
"X-Apple-Client-Name" to "Grayjay"
|
||||
) )
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Encrypted POST failed to $path", e)
|
||||
false
|
||||
}
|
||||
}*/
|
||||
|
||||
private fun Map<String,Any>.toNSDictionary(): NSDictionary {
|
||||
val dict = NSDictionary()
|
||||
forEach { (k,v) ->
|
||||
when (v) {
|
||||
is String -> dict[k] = NSString(v)
|
||||
is Double -> dict[k] = NSNumber(v)
|
||||
is Long -> dict[k] = NSNumber(v)
|
||||
is Int -> dict[k] = NSNumber(v)
|
||||
is Boolean -> dict[k] = if (v) NSNumber(true) else NSNumber(false)
|
||||
else -> throw IllegalArgumentException("Unsupported plist value type: ${v.javaClass}")
|
||||
}
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private fun encryptData(data: ByteArray): ByteArray {
|
||||
if (!_isEncrypted || _outgoingKey == null) return data
|
||||
val result = ByteArrayOutputStream()
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val length = minOf(data.size - offset, MAX_BLOCK_LENGTH)
|
||||
val blockData = data.copyOfRange(offset, offset + length)
|
||||
val lengthData = ByteBuffer.allocate(2).putShort(length.toShort()).array()
|
||||
val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_outCount.toLong()).array()
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(_outgoingKey!!, nonce, lengthData, blockData)
|
||||
result.write(lengthData)
|
||||
result.write(ciphertext)
|
||||
result.write(mac)
|
||||
offset += length
|
||||
_outCount++
|
||||
}
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
private fun decryptData(data: ByteArray): ByteArray? {
|
||||
if (!_isEncrypted || _incomingKey == null || data.size < 2 + TAG_LENGTH) return null
|
||||
val length = ByteBuffer.wrap(data, 0, 2).short.toInt() and 0xFFFF
|
||||
if (data.size < 2 + length + TAG_LENGTH) return null
|
||||
val blockData = data.copyOfRange(2, 2 + length)
|
||||
val mac = data.copyOfRange(2 + length, 2 + length + TAG_LENGTH)
|
||||
val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_inCount.toLong()).array()
|
||||
val plaintext = chacha20Poly1305Decrypt(_incomingKey!!, nonce, byteArrayOf(), blockData, mac)
|
||||
if (plaintext != null) _inCount++
|
||||
return plaintext
|
||||
}
|
||||
|
||||
private fun pairingDidFail(message: String, e: Throwable? = null) {
|
||||
_state = AirPlaySenderState.PAIRING_FAILED
|
||||
if (e != null)
|
||||
Logger.e(TAG, "Pairing failed with message '${message}'.", e)
|
||||
else
|
||||
Logger.e(TAG, "Pairing failed with message '${message}'.")
|
||||
}
|
||||
|
||||
private fun pairingDidFinish() {
|
||||
Logger.i(TAG, "Pairing succeeded. Device is ready.")
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
_state = AirPlaySenderState.READY_TO_PLAY
|
||||
_paired = true
|
||||
|
||||
//TODO: Do something?
|
||||
}
|
||||
|
||||
private fun postHttp(path: String, bodyBytes: ByteArray, contentType: String?): Boolean? {
|
||||
val url = getUrl(path)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(bodyBytes.toRequestBody(contentType?.toMediaType()))
|
||||
.header("User-Agent", "AirPlay/381.13")
|
||||
.header("X-Apple-HKP", "3")
|
||||
.header("CSeq", (_cseq++).toString())
|
||||
.apply { if (contentType != null) header("Content-Type", contentType) }
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(request).execute().use { response ->
|
||||
response.isSuccessful
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "HTTP POST failed: $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun postHttpWithResponse(path: String, bodyBytes: ByteArray, headers: Map<String, String>?): Response? {
|
||||
val url = getUrl(path)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(bodyBytes.toRequestBody(headers?.get("Content-Type")?.toMediaType()))
|
||||
.header("User-Agent", "AirPlay/381.13")
|
||||
.header("X-Apple-HKP", "3")
|
||||
.header("X-Apple-Client-Name", "Grayjay")
|
||||
.apply {
|
||||
headers?.forEach { (k, v) -> header(k, v) }
|
||||
}
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "HTTP POST failed: $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hkdfExtractExpand(ikm: ByteArray, salt: ByteArray?, info: ByteArray?, length: Int): ByteArray {
|
||||
val hkdf = HKDFBytesGenerator(SHA512Digest())
|
||||
val params = HKDFParameters(ikm, salt, info)
|
||||
hkdf.init(params)
|
||||
val output = ByteArray(length)
|
||||
hkdf.generateBytes(output, 0, length)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun chacha20Poly1305Encrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val aead = ChaCha20Poly1305()
|
||||
aead.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))
|
||||
|
||||
val output = ByteArray(plaintext.size + 16)
|
||||
var offset = aead.processBytes(plaintext, 0, plaintext.size, output, 0)
|
||||
aead.doFinal(output, offset)
|
||||
|
||||
val ciphertext = output.copyOf(plaintext.size)
|
||||
val tag = output.copyOfRange(plaintext.size, output.size)
|
||||
return Pair(ciphertext, tag)
|
||||
}
|
||||
|
||||
private fun chacha20Poly1305Decrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray, mac: ByteArray): ByteArray? {
|
||||
val aead = ChaCha20Poly1305()
|
||||
aead.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))
|
||||
|
||||
val input = ciphertext + mac
|
||||
val output = ByteArray(ciphertext.size)
|
||||
var len = aead.processBytes(input, 0, input.size, output, 0)
|
||||
return try {
|
||||
aead.doFinal(output, len)
|
||||
output
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun concat(vararg arrays: ByteArray): ByteArray {
|
||||
val totalLength = arrays.sumOf { it.size }
|
||||
val result = ByteArray(totalLength)
|
||||
var offset = 0
|
||||
for (array in arrays) {
|
||||
System.arraycopy(array, 0, result, offset, array.size)
|
||||
offset += array.size
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum class AirPlaySenderState {
|
||||
NOT_CONNECTED,
|
||||
WAITING_ON_PAIR_PIN_START,
|
||||
WAITING_ON_PAIR_SETUP1,
|
||||
WAITING_ON_PAIR_SETUP2,
|
||||
WAITING_ON_PAIR_SETUP3,
|
||||
WAITING_ON_PAIR_VERIFY1,
|
||||
WAITING_ON_PAIR_VERIFY2,
|
||||
READY_TO_PLAY,
|
||||
CANCELLED,
|
||||
PAIRING_FAILED
|
||||
}
|
||||
|
||||
enum class PairingState(val value: UByte) {
|
||||
M1(1u),
|
||||
M2(2u),
|
||||
M3(3u),
|
||||
M4(4u),
|
||||
M5(5u),
|
||||
M6(6u)
|
||||
}
|
||||
|
||||
enum class PairingMethod(val value: UByte) {
|
||||
PAIR_SETUP(0u),
|
||||
PAIR_SETUP_WITH_AUTH(1u),
|
||||
PAIR_VERIFY(2u),
|
||||
ADD_PAIRING(3u),
|
||||
REMOVE_PAIRING(4u),
|
||||
LIST_PAIRINGS(5u)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -21,7 +22,8 @@ enum class CastConnectionState {
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
FCAST,
|
||||
AIRPLAY2;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
@@ -40,23 +42,29 @@ enum class CastProtocolType {
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDevice {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
interface IPairingDataHandler {
|
||||
fun savePairingData(deviceId: String, pairingData: ByteArray)
|
||||
fun loadPairingData(deviceId: String): ByteArray?
|
||||
fun clearPairingData(deviceId: String)
|
||||
}
|
||||
|
||||
var name: String? = null;
|
||||
abstract class CastingDevice {
|
||||
abstract val protocol: CastProtocolType
|
||||
abstract val isReady: Boolean
|
||||
abstract var usedRemoteAddress: InetAddress?
|
||||
abstract var localAddress: InetAddress?
|
||||
abstract val canSetVolume: Boolean
|
||||
abstract val canSetSpeed: Boolean
|
||||
|
||||
var name: String? = null
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
val changed = value != field
|
||||
field = value
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
onPlayChanged.emit(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
@@ -111,38 +119,42 @@ abstract class CastingDevice {
|
||||
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
val changed = value != field
|
||||
field = value
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
onConnectionStateChanged.emit(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
var onPairingPinRequired = Event0()
|
||||
open fun providePairingPin(pin: String?) { throw NotImplementedError() }
|
||||
|
||||
abstract fun stopCasting();
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>()
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
|
||||
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
|
||||
abstract fun stopCasting()
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double)
|
||||
abstract fun stopVideo()
|
||||
abstract fun pauseVideo()
|
||||
abstract fun resumeVideo()
|
||||
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?)
|
||||
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?)
|
||||
open fun changeVolume(volume: Double) { throw NotImplementedError() }
|
||||
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
abstract fun start()
|
||||
abstract fun stop()
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = false; //TODO: Implement
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
@@ -144,6 +144,23 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
||||
|
||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
||||
setSpeed(speedClamped)
|
||||
val mediaSessionId = _mediaSessionId ?: return
|
||||
val transportId = _transportId ?: return
|
||||
val setSpeedObject = JSONObject().apply {
|
||||
put("type", "SET_PLAYBACK_RATE")
|
||||
put("mediaSessionId", mediaSessionId)
|
||||
put("playbackRate", speedClamped)
|
||||
put("requestId", _requestId++)
|
||||
}
|
||||
|
||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.stripLeadingZero
|
||||
import org.bouncycastle.crypto.digests.SHA512Digest
|
||||
import java.math.BigInteger
|
||||
import java.security.SecureRandom
|
||||
|
||||
class SRPClient(private val N: BigInteger, private val g: BigInteger, private val username: String, private val password: String) {
|
||||
private val digest = SHA512Digest()
|
||||
private val hashLen = digest.digestSize
|
||||
private val PAD_L: Int = (N.bitLength() + 7) / 8
|
||||
|
||||
private var a: BigInteger? = null
|
||||
private var A: BigInteger? = null
|
||||
private var S: BigInteger? = null
|
||||
private var sessionKey: ByteArray? = null
|
||||
private var M: ByteArray? = null
|
||||
private var HAMK: ByteArray? = null
|
||||
private var authenticated: Boolean = false
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun isAuthenticated(): Boolean = authenticated
|
||||
fun getSessionKey(): ByteArray? = sessionKey
|
||||
|
||||
fun srp_user_start_authentication(aOverride: BigInteger? = null): BigInteger {
|
||||
a = aOverride ?: BigInteger(256, random)
|
||||
A = g.modPow(a, N)
|
||||
|
||||
if (A!!.mod(N).signum() == 0) {
|
||||
throw IllegalStateException("Invalid client parameter: A mod N = 0")
|
||||
}
|
||||
|
||||
return A!!
|
||||
}
|
||||
|
||||
fun getS(): ByteArray? = S?.toByteArray()?.stripLeadingZero()
|
||||
fun getA(): ByteArray? = A?.toByteArray()?.stripLeadingZero()
|
||||
|
||||
fun srp_user_process_challenge(saltBytes: ByteArray, BBytes: ByteArray): ByteArray {
|
||||
return srp_user_process_challenge_internal(saltBytes, BBytes).third
|
||||
}
|
||||
|
||||
fun srp_user_process_challenge_internal(saltBytes: ByteArray, BBytes: ByteArray): Triple<BigInteger, BigInteger, ByteArray> {
|
||||
if (A == null || a == null) {
|
||||
throw IllegalStateException("Must call srp_user_start_authentication() first.")
|
||||
}
|
||||
|
||||
val B = BigInteger(1, BBytes)
|
||||
val u = H_nn(A!!, B)
|
||||
if (u.signum() == 0) {
|
||||
throw IllegalStateException("Invalid server parameter: u = 0")
|
||||
}
|
||||
|
||||
val x = calculate_x(BigInteger(1, saltBytes))
|
||||
val k = H_nn(N, g)
|
||||
val v = g.modPow(x, N)
|
||||
if (B.mod(N).signum() == 0) {
|
||||
throw IllegalStateException("Invalid server parameter: B mod N = 0")
|
||||
}
|
||||
|
||||
val kv = k.multiply(v).mod(N)
|
||||
val base = B.subtract(kv).mod(N)
|
||||
val exponent = a!!.add(u.multiply(x))
|
||||
S = base.modPow(exponent, N)
|
||||
|
||||
sessionKey = hashBigInteger(S!!)
|
||||
M = calculate_M(saltBytes, A!!, B, sessionKey!!)
|
||||
return Triple(u, v, M!!.clone())
|
||||
}
|
||||
|
||||
fun srp_user_verify_session(serverHAMK: ByteArray): Boolean {
|
||||
if (M == null || sessionKey == null || A == null) {
|
||||
throw IllegalStateException("Must call srp_user_process_challenge() first.")
|
||||
}
|
||||
|
||||
val hamk = calculate_H_AMK(A!!, M!!, sessionKey!!)
|
||||
HAMK = hamk
|
||||
|
||||
authenticated = HAMK!!.contentEquals(serverHAMK)
|
||||
return authenticated
|
||||
}
|
||||
|
||||
private fun H_padded(vararg inputs: BigInteger): BigInteger {
|
||||
val allBytes = inputs.fold(ByteArray(0)) { acc, big -> acc + padTo(big, PAD_L) }
|
||||
val d = SHA512Digest()
|
||||
d.update(allBytes, 0, allBytes.size)
|
||||
val out = ByteArray(hashLen)
|
||||
d.doFinal(out, 0)
|
||||
return BigInteger(1, out)
|
||||
}
|
||||
|
||||
private fun H_nn(bn1: BigInteger, bn2: BigInteger): BigInteger {
|
||||
return H_padded(bn1, bn2)
|
||||
}
|
||||
|
||||
private fun H_ns(n: BigInteger, saltBytes: ByteArray): BigInteger {
|
||||
val nMinimal = n.toByteArray().stripLeadingZero()
|
||||
val concatenated = nMinimal + saltBytes
|
||||
val digest = SHA512Digest()
|
||||
digest.update(concatenated, 0, concatenated.size)
|
||||
val out = ByteArray(hashLen)
|
||||
digest.doFinal(out, 0)
|
||||
return BigInteger(1, out)
|
||||
}
|
||||
|
||||
private fun calculate_x(salt: BigInteger): BigInteger {
|
||||
val userColonPass = username.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x3A /* : */) + password.toByteArray(Charsets.US_ASCII)
|
||||
val ucpHash = hash(userColonPass)
|
||||
return H_ns(salt, ucpHash)
|
||||
}
|
||||
|
||||
private fun hashBigInteger(value: BigInteger): ByteArray {
|
||||
val raw = value.toByteArray().stripLeadingZero()
|
||||
return hash(raw)
|
||||
}
|
||||
|
||||
private fun hash(data: ByteArray): ByteArray {
|
||||
val d = SHA512Digest()
|
||||
d.update(data, 0, data.size)
|
||||
val out = ByteArray(hashLen)
|
||||
d.doFinal(out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun calculate_M(saltBytes: ByteArray, Aint: BigInteger, Bint: BigInteger, K: ByteArray): ByteArray {
|
||||
val H_N = hashBigInteger(N)
|
||||
val H_g = hashBigInteger(g)
|
||||
val H_xor = ByteArray(hashLen) { i -> (H_N[i].toInt() xor H_g[i].toInt()).toByte() }
|
||||
val H_I = hash(username.toByteArray(Charsets.UTF_8))
|
||||
val Abytes = Aint.toByteArray().stripLeadingZero()
|
||||
val Bbytes = Bint.toByteArray().stripLeadingZero()
|
||||
val mDigest = SHA512Digest()
|
||||
mDigest.update(H_xor, 0, hashLen)
|
||||
mDigest.update(H_I, 0, hashLen)
|
||||
mDigest.update(saltBytes, 0, saltBytes.size)
|
||||
mDigest.update(Abytes, 0, Abytes.size)
|
||||
mDigest.update(Bbytes, 0, Bbytes.size)
|
||||
mDigest.update(K, 0, hashLen)
|
||||
val mOut = ByteArray(hashLen)
|
||||
mDigest.doFinal(mOut, 0)
|
||||
return mOut
|
||||
}
|
||||
|
||||
private fun calculate_H_AMK(Aint: BigInteger, M: ByteArray, K: ByteArray): ByteArray {
|
||||
val Abytes = Aint.toByteArray().stripLeadingZero()
|
||||
val hamkDigest = SHA512Digest()
|
||||
hamkDigest.update(Abytes, 0, Abytes.size)
|
||||
hamkDigest.update(M, 0, hashLen)
|
||||
hamkDigest.update(K, 0, hashLen)
|
||||
val out = ByteArray(hashLen)
|
||||
hamkDigest.doFinal(out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun padTo(value: BigInteger, length: Int): ByteArray {
|
||||
val minimal = value.toByteArray().stripLeadingZero()
|
||||
return if (minimal.size == length) {
|
||||
minimal
|
||||
} else if (minimal.size < length) {
|
||||
ByteArray(length - minimal.size) { 0 } + minimal
|
||||
} else {
|
||||
minimal.copyOfRange(minimal.size - length, minimal.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
enum class TLV8Tag(val value: UByte) {
|
||||
METHOD(0u),
|
||||
IDENTIFIER(1u),
|
||||
SALT(2u),
|
||||
PUBLIC_KEY(3u),
|
||||
PROOF(4u),
|
||||
ENCRYPTED_DATA(5u),
|
||||
STATE(6u),
|
||||
ERROR(7u),
|
||||
RETRY_DELAY(8u),
|
||||
CERTIFICATE(9u),
|
||||
SIGNATURE(0x0Au),
|
||||
PERMISSIONS(0x0Bu),
|
||||
FRAGMENT_DATA(0x0Cu),
|
||||
FRAGMENT_LAST(0x0Du),
|
||||
FLAGS(0x13u),
|
||||
SEPARATOR(0xFFu)
|
||||
}
|
||||
|
||||
data class TLV8Item(val tag: TLV8Tag, val value: UByteArray) {
|
||||
override fun toString(): String {
|
||||
val tagHex = "%02X".format(tag.value.toInt())
|
||||
val dataHex = value.joinToString(" ") { "%02X".format(it.toInt()) }
|
||||
return "${tag.name}(0x$tagHex): $dataHex"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPlayTLV8"
|
||||
private const val FRAGMENT_THRESHOLD = 0xFF
|
||||
|
||||
fun decodeAndReassembleWithLogging(data: UByteArray): Map<TLV8Tag, ByteArray> {
|
||||
val items = decode(data)
|
||||
Logger.i(TAG, "Raw TLV8 items:\n" + items.joinToString("\n") { it.toString() })
|
||||
|
||||
val fields = items
|
||||
.groupBy { it.tag }
|
||||
.mapValues { (_, chunk) ->
|
||||
chunk.fold(UByteArray(0)) { acc, item -> acc + item.value }.toByteArray()
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Reassembled TLV8 fields:\n" +
|
||||
fields.entries.joinToString("\n") { (tag, bytes) ->
|
||||
"%-12s: %s".format(tag.name,
|
||||
bytes.joinToString(" ") { "%02X".format(it) })
|
||||
}
|
||||
)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
fun decodeAsString(data: UByteArray): String {
|
||||
return decode(data).joinToString("\n") { it.toString() }
|
||||
}
|
||||
|
||||
fun itemsToString(items: List<TLV8Item>): String = items.joinToString(separator = "\n") { it.toString() }
|
||||
|
||||
fun encodeWithLogging(items: List<TLV8Item>, useFragmentData: Boolean = false): ByteArray {
|
||||
Logger.i(TAG, "Assembled TLV8 items:\n" + itemsToString(items))
|
||||
|
||||
val fragments = if (useFragmentData) fragmentStandard(items) else fragmentRepeat(items)
|
||||
Logger.i(TAG, "Split TLV8 items:\n" + itemsToString(fragments))
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
fragments.forEach { frag ->
|
||||
val data = frag.value.asByteArray()
|
||||
out.write(frag.tag.value.toInt())
|
||||
out.write(data.size)
|
||||
out.write(data)
|
||||
}
|
||||
val encoded = out.toByteArray()
|
||||
val hexStream = encoded.joinToString(" ") { "%02X".format(it) }
|
||||
Logger.i(TAG, "Final TLV8 byte stream (${encoded.size} bytes):\n$hexStream")
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
private fun fragmentStandard(items: List<TLV8Item>): List<TLV8Item> {
|
||||
val frags = mutableListOf<TLV8Item>()
|
||||
items.forEach { item ->
|
||||
val bytes = item.value.asByteArray()
|
||||
if (bytes.size <= FRAGMENT_THRESHOLD) {
|
||||
frags += item
|
||||
} else {
|
||||
var offset = 0
|
||||
// first fragment with original tag
|
||||
frags += TLV8Item(item.tag, bytes.copyOfRange(0, FRAGMENT_THRESHOLD).toUByteArray())
|
||||
offset += FRAGMENT_THRESHOLD
|
||||
|
||||
// middle fragments
|
||||
while (bytes.size - offset > FRAGMENT_THRESHOLD) {
|
||||
frags += TLV8Item(
|
||||
TLV8Tag.FRAGMENT_DATA,
|
||||
bytes.copyOfRange(offset, offset + FRAGMENT_THRESHOLD).toUByteArray()
|
||||
)
|
||||
offset += FRAGMENT_THRESHOLD
|
||||
}
|
||||
|
||||
// last fragment
|
||||
val rem = bytes.size - offset
|
||||
frags += TLV8Item(
|
||||
TLV8Tag.FRAGMENT_LAST,
|
||||
bytes.copyOfRange(offset, offset + rem).toUByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
return frags
|
||||
}
|
||||
|
||||
private fun fragmentRepeat(items: List<TLV8Item>): List<TLV8Item> {
|
||||
val frags = mutableListOf<TLV8Item>()
|
||||
items.forEach { item ->
|
||||
val bytes = item.value.asByteArray()
|
||||
var offset = 0
|
||||
while (offset < bytes.size) {
|
||||
val chunk = minOf(FRAGMENT_THRESHOLD, bytes.size - offset)
|
||||
frags += TLV8Item(
|
||||
item.tag,
|
||||
bytes.copyOfRange(offset, offset + chunk).toUByteArray()
|
||||
)
|
||||
offset += chunk
|
||||
}
|
||||
}
|
||||
return frags
|
||||
}
|
||||
|
||||
fun decode(data: UByteArray): List<TLV8Item> {
|
||||
val items = mutableListOf<TLV8Item>()
|
||||
var i = 0
|
||||
|
||||
while (i < data.size) {
|
||||
val tagByte = data[i]
|
||||
val tag = TLV8Tag.values().find { it.value == tagByte }
|
||||
?: throw IllegalArgumentException("Unknown tag 0x${tagByte.toString(16)} at offset $i")
|
||||
if (i + 1 >= data.size) {
|
||||
throw IllegalArgumentException("Truncated TLV: no length byte for tag $tag at offset $i")
|
||||
}
|
||||
|
||||
val length = data[i + 1].toInt() and 0xFF
|
||||
i += 2
|
||||
if (i + length > data.size) {
|
||||
throw IllegalArgumentException("Truncated TLV: declared length $length exceeds available bytes (${data.size - i})")
|
||||
}
|
||||
|
||||
var value = data.copyOfRange(i, i + length)
|
||||
i += length
|
||||
|
||||
if (length == FRAGMENT_THRESHOLD && i < data.size) {
|
||||
val nextTag = data[i]
|
||||
if (nextTag == TLV8Tag.FRAGMENT_DATA.value ||
|
||||
nextTag == TLV8Tag.FRAGMENT_LAST.value
|
||||
) {
|
||||
while (true) {
|
||||
if (i + 2 > data.size) {
|
||||
throw IllegalArgumentException("Truncated fragment header at offset $i")
|
||||
}
|
||||
val fragTagByte = data[i]
|
||||
val fragTag = TLV8Tag.values().find { it.value == fragTagByte }
|
||||
?: throw IllegalArgumentException("Unknown fragment tag 0x${fragTagByte.toString(16)} at offset $i")
|
||||
val fragLen = data[i + 1].toInt() and 0xFF
|
||||
i += 2
|
||||
if (i + fragLen > data.size) {
|
||||
throw IllegalArgumentException("Truncated fragment: declared length $fragLen exceeds available bytes (${data.size - i})")
|
||||
}
|
||||
val fragData = data.copyOfRange(i, i + fragLen)
|
||||
value += fragData
|
||||
i += fragLen
|
||||
|
||||
if (fragTag == TLV8Tag.FRAGMENT_LAST) break
|
||||
if (fragTag != TLV8Tag.FRAGMENT_DATA) {
|
||||
throw IllegalArgumentException("Unexpected tag $fragTag in fragment sequence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items += TLV8Item(tag, value)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name && it.castingDevice.protocol == d.protocol }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
@@ -161,20 +161,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
return oldList[oldItemPosition].castingDevice.name == newList[newItemPosition].castingDevice.name && oldList[oldItemPosition].castingDevice.protocol == newList[newItemPosition].castingDevice.protocol
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
|
||||
return oldItem == newItem
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay2CastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
@@ -175,9 +176,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
} else if (d is AirPlay1CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is AirPlay2CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.airplay_audio_logo);
|
||||
_textType.text = "AirPlay 2";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FastCast";
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
||||
class PairingCodeDialog(context: Context?, private val onSubmit: (code: String) -> Unit) : AlertDialog(context) {
|
||||
private lateinit var _editPairingCode: EditText
|
||||
private lateinit var _textError: TextView
|
||||
private lateinit var _buttonSubmit: LinearLayout
|
||||
private lateinit var _buttonCancel: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_pairing_code, null))
|
||||
|
||||
_editPairingCode = findViewById(R.id.edit_pairing_code)
|
||||
_textError = findViewById(R.id.text_error)
|
||||
_buttonSubmit = findViewById(R.id.button_submit)
|
||||
_buttonCancel = findViewById(R.id.button_cancel)
|
||||
|
||||
setTitle("Enter Pairing Code")
|
||||
|
||||
_buttonCancel.setOnClickListener {
|
||||
performDismiss()
|
||||
}
|
||||
|
||||
_buttonSubmit.setOnClickListener {
|
||||
val code = _editPairingCode.text.toString().trim()
|
||||
if (code.isBlank()) {
|
||||
_textError.text = "Pairing code cannot be empty."
|
||||
_textError.visibility = View.VISIBLE
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
_textError.visibility = View.GONE
|
||||
onSubmit(code)
|
||||
performDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
super.show()
|
||||
|
||||
_editPairingCode.text.clear()
|
||||
_textError.visibility = View.GONE
|
||||
|
||||
window?.apply {
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDismiss() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "PairingCodeDialog"
|
||||
}
|
||||
}
|
||||
@@ -724,7 +724,7 @@ class VideoDownload {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
|
||||
@@ -6,8 +6,6 @@ import com.caoccao.javet.exceptions.JavetException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
import com.caoccao.javet.interop.V8Host
|
||||
import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.interop.options.V8Flags
|
||||
import com.caoccao.javet.interop.options.V8RuntimeOptions
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
@@ -26,6 +24,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.engine.internal.V8Converter
|
||||
@@ -40,6 +39,8 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class V8Plugin {
|
||||
val config: IV8PluginConfig;
|
||||
@@ -47,10 +48,13 @@ class V8Plugin {
|
||||
private val _clientAuth: ManagedHttpClient;
|
||||
private val _clientOthers: ConcurrentHashMap<String, JSHttpClient> = ConcurrentHashMap();
|
||||
|
||||
|
||||
val httpClient: ManagedHttpClient get() = _client;
|
||||
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
|
||||
val httpClientOthers: Map<String, JSHttpClient> get() = _clientOthers;
|
||||
|
||||
var runtimeId: Int = 0;
|
||||
|
||||
fun registerHttpClient(client: JSHttpClient) {
|
||||
synchronized(_clientOthers) {
|
||||
_clientOthers.put(client.clientId, client);
|
||||
@@ -67,10 +71,8 @@ class V8Plugin {
|
||||
var isStopped = true;
|
||||
val onStopped = Event1<V8Plugin>();
|
||||
|
||||
//TODO: Implement a more universal isBusy system for plugins + JSClient + pooling? TBD if propagation would be beneficial
|
||||
private val _busyCounterLock = Object();
|
||||
private var _busyCounter = 0;
|
||||
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
|
||||
private val _busyLock = ReentrantLock()
|
||||
val isBusy get() = _busyLock.isLocked;
|
||||
|
||||
var allowDevSubmit: Boolean = false
|
||||
private set(value) {
|
||||
@@ -140,6 +142,7 @@ class V8Plugin {
|
||||
synchronized(_runtimeLock) {
|
||||
if (_runtime != null)
|
||||
return;
|
||||
runtimeId = runtimeId + 1;
|
||||
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
|
||||
val host = V8Host.getV8Instance();
|
||||
val options = host.jsRuntimeType.getRuntimeOptions();
|
||||
@@ -148,6 +151,8 @@ class V8Plugin {
|
||||
if (!host.isIsolateCreated)
|
||||
throw IllegalStateException("Isolate not created");
|
||||
|
||||
_runtimeMap.put(_runtime!!, this);
|
||||
|
||||
//Setup bridge
|
||||
_runtime?.let {
|
||||
it.converter = V8Converter();
|
||||
@@ -184,10 +189,13 @@ class V8Plugin {
|
||||
}
|
||||
fun stop(){
|
||||
Logger.i(TAG, "Stopping plugin [${config.name}]");
|
||||
isStopped = true;
|
||||
whenNotBusy {
|
||||
busy {
|
||||
Logger.i(TAG, "Plugin stopping");
|
||||
synchronized(_runtimeLock) {
|
||||
if(isStopped)
|
||||
return@busy;
|
||||
isStopped = true;
|
||||
runtimeId = runtimeId + 1;
|
||||
|
||||
//Cleanup http
|
||||
for(pack in _depsPackages) {
|
||||
@@ -197,6 +205,7 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
_runtime?.let {
|
||||
_runtimeMap.remove(it);
|
||||
_runtime = null;
|
||||
if(!it.isClosed && !it.isDead) {
|
||||
try {
|
||||
@@ -211,10 +220,20 @@ class V8Plugin {
|
||||
Logger.i(TAG, "Stopped plugin [${config.name}]");
|
||||
};
|
||||
}
|
||||
Logger.i(TAG, "Plugin stopped");
|
||||
onStopped.emit(this);
|
||||
}
|
||||
}
|
||||
|
||||
fun isThreadAlreadyBusy(): Boolean {
|
||||
return _busyLock.isHeldByCurrentThread;
|
||||
}
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
_busyLock.withLock {
|
||||
//Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString());
|
||||
return handle();
|
||||
}
|
||||
}
|
||||
fun execute(js: String) : V8Value {
|
||||
return executeTyped<V8Value>(js);
|
||||
}
|
||||
@@ -223,49 +242,17 @@ class V8Plugin {
|
||||
if(isStopped)
|
||||
throw PluginEngineStoppedException(config, "Instance is stopped", js);
|
||||
|
||||
synchronized(_busyCounterLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
return busy {
|
||||
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
try {
|
||||
return catchScriptErrors("Plugin[${config.name}]", js) {
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
return@busy catchScriptErrors("Plugin[${config.name}]", js) {
|
||||
runtime.getExecutor(js).execute()
|
||||
};
|
||||
}
|
||||
finally {
|
||||
synchronized(_busyCounterLock) {
|
||||
//Free busy *after* afterBusy calls are done to prevent calls on dead runtimes
|
||||
try {
|
||||
afterBusy.emit(_busyCounter - 1);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Unhandled V8Plugin.afterBusy", ex);
|
||||
}
|
||||
_busyCounter--;
|
||||
}
|
||||
}
|
||||
}
|
||||
fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value };
|
||||
fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value };
|
||||
fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value };
|
||||
|
||||
fun whenNotBusy(handler: (V8Plugin)->Unit) {
|
||||
synchronized(_busyCounterLock) {
|
||||
if(_busyCounter == 0)
|
||||
handler(this);
|
||||
else {
|
||||
val tag = Object();
|
||||
afterBusy.subscribe(tag) {
|
||||
if(it == 0) {
|
||||
Logger.w(TAG, "V8Plugin afterBusy handled");
|
||||
afterBusy.remove(tag);
|
||||
handler(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun executeBoolean(js: String) : Boolean? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value } }
|
||||
fun executeString(js: String) : String? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value } }
|
||||
fun executeInteger(js: String) : Int? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value } }
|
||||
|
||||
private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? {
|
||||
//TODO: Auto get all package types?
|
||||
@@ -292,8 +279,14 @@ class V8Plugin {
|
||||
private val REGEX_EX_FALLBACK = Regex(".*throw.*?[\"](.*)[\"].*");
|
||||
private val REGEX_EX_FALLBACK2 = Regex(".*throw.*?['](.*)['].*");
|
||||
|
||||
private val _runtimeMap = ConcurrentHashMap<V8Runtime, V8Plugin>();
|
||||
|
||||
val TAG = "V8Plugin";
|
||||
|
||||
fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? {
|
||||
return _runtimeMap.getOrDefault(runtime, null);
|
||||
}
|
||||
|
||||
fun <T: Any?> catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T {
|
||||
var codeStripped = code;
|
||||
if(codeStripped != null) { //TODO: Improve code stripped
|
||||
@@ -327,14 +320,23 @@ class V8Plugin {
|
||||
throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
|
||||
}
|
||||
catch(executeEx: JavetExecutionException) {
|
||||
if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) {
|
||||
val pluginType = executeEx.scriptingError.context["plugin_type"].toString();
|
||||
val obj = executeEx.scriptingError?.context
|
||||
if(obj != null && obj.containsKey("plugin_type") == true) {
|
||||
val pluginType = obj["plugin_type"].toString();
|
||||
|
||||
//Captcha
|
||||
if (pluginType == "CaptchaRequiredException") {
|
||||
throw ScriptCaptchaRequiredException(config,
|
||||
executeEx.scriptingError.context["url"]?.toString(),
|
||||
executeEx.scriptingError.context["body"]?.toString(),
|
||||
obj["url"]?.toString(),
|
||||
obj["body"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
//Reload Required
|
||||
if (pluginType == "ReloadRequiredException") {
|
||||
throw ScriptReloadRequiredException(config,
|
||||
obj["msg"]?.toString(),
|
||||
obj["reloadData"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
@@ -348,6 +350,41 @@ class V8Plugin {
|
||||
codeStripped
|
||||
);
|
||||
}
|
||||
/* //Required for newer V8 versions
|
||||
if(executeEx.scriptingError?.context is IJavetEntityError) {
|
||||
val obj = executeEx.scriptingError?.context as IJavetEntityError
|
||||
if(obj.context.containsKey("plugin_type") == true) {
|
||||
val pluginType = obj.context["plugin_type"].toString();
|
||||
|
||||
//Captcha
|
||||
if (pluginType == "CaptchaRequiredException") {
|
||||
throw ScriptCaptchaRequiredException(config,
|
||||
obj.context["url"]?.toString(),
|
||||
obj.context["body"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
//Reload Required
|
||||
if (pluginType == "ReloadRequiredException") {
|
||||
throw ScriptReloadRequiredException(config,
|
||||
obj.context["msg"]?.toString(),
|
||||
obj.context["reloadData"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
//Others
|
||||
throwExceptionFromV8(
|
||||
config,
|
||||
pluginType,
|
||||
(extractJSExceptionMessage(executeEx) ?: ""),
|
||||
executeEx,
|
||||
executeEx.scriptingError?.stack,
|
||||
codeStripped
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
||||
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
@@ -398,9 +435,4 @@ class V8Plugin {
|
||||
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Methods available for scripts (bridge object)
|
||||
*/
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class NoInternetException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
@@ -11,6 +12,7 @@ open class NoInternetException(config: IV8PluginConfig, error: String, ex: Excep
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : NoInternetException {
|
||||
obj.ensureIsBusy();
|
||||
return NoInternetException(config, obj.getOrThrow(config, "message", "NoInternetException"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class ScriptAgeException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
@@ -11,6 +12,7 @@ open class ScriptAgeException(config: IV8PluginConfig, error: String, ex: Except
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptException(config, obj.getOrThrow(config, "message", "ScriptAgeException"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -9,6 +10,7 @@ class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String?,
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "ScriptCaptchaRequiredException";
|
||||
return ScriptCaptchaRequiredException(config,
|
||||
obj.getOrDefault<String>(config, "url", contextName, null),
|
||||
|
||||
+2
@@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptCompilationException(config: IV8PluginConfig, error: String, ex: Exception? = null, code: String? = null) : PluginException(config, error, ex, code) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptCompilationException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptCompilationException(config, obj.getOrThrow(config, "message", "ScriptCompilationException"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
@@ -11,6 +12,7 @@ open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: E
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptCriticalException(config, obj.getOrThrow(config, "message", "ScriptCriticalException"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class ScriptException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptExecutionException(config, error, ex, stack, code) {
|
||||
@@ -11,6 +12,7 @@ open class ScriptException(config: IV8PluginConfig, error: String, ex: Exception
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptException(config, obj.getOrThrow(config, "message", "ScriptException"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class ScriptExecutionException(config: IV8PluginConfig, error: String, ex: Exception? = null, val stack: String? = null, code: String? = null) : PluginException(config, error, ex, code) {
|
||||
@@ -11,6 +12,7 @@ open class ScriptExecutionException(config: IV8PluginConfig, error: String, ex:
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptExecutionException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptExecutionException(config, obj.getOrThrow(config, "message", "ScriptExecutionException"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptImplementationException(config: IV8PluginConfig, error: String, ex: Exception? = null, var pluginId: String? = null, code: String? = null) : PluginException(config, error, ex, code) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptImplementationException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptImplementationException(config, obj.getOrThrow(config, "message", "ScriptImplementationException"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptLoginRequiredException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptLoginRequiredException(config, obj.getOrThrow(config, "message", "ScriptLoginRequiredException"));
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptReloadRequiredException(config: IV8PluginConfig, val msg: String?, val reloadData: String?, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, msg ?: "ReloadRequired", ex, stack, code) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "ScriptReloadRequiredException";
|
||||
return ScriptReloadRequiredException(config,
|
||||
obj.getOrThrow(config, "message", contextName),
|
||||
obj.getOrDefault<String>(config, "reloadData", contextName, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptTimeoutException(config: IV8PluginConfig, error: String, ex: Exception? = null) : ScriptException(config, error, ex) {
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptTimeoutException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptTimeoutException(config, obj.getOrThrow(config, "message", "ScriptException"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class ScriptUnavailableException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
obj.ensureIsBusy();
|
||||
return ScriptUnavailableException(config, obj.getOrThrow(config, "message", "ScriptUnavailableException"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ open class V8BindObject : IV8Convertable {
|
||||
|
||||
override fun toV8(runtime: V8Runtime): V8Value? {
|
||||
synchronized(this) {
|
||||
if(_runtimeObj != null)
|
||||
return _runtimeObj;
|
||||
//if(_runtimeObj != null)
|
||||
// return _runtimeObj;
|
||||
|
||||
val v8Obj = runtime.createV8ValueObject();
|
||||
v8Obj.bind(this);
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.media.MediaCodec
|
||||
import android.media.MediaCodecList
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.annotations.V8Property
|
||||
import com.caoccao.javet.interop.callback.JavetCallbackContext
|
||||
import com.caoccao.javet.utils.JavetResourceUtils
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PackageBridge : V8Package {
|
||||
@Transient
|
||||
@@ -78,6 +80,14 @@ class PackageBridge : V8Package {
|
||||
return "android";
|
||||
}
|
||||
|
||||
@V8Property
|
||||
fun supportedFeatures(): Array<String> {
|
||||
return arrayOf(
|
||||
"ReloadRequiredException",
|
||||
"HttpBatchClient"
|
||||
);
|
||||
}
|
||||
|
||||
@V8Property
|
||||
fun supportedContent(): Array<Int> {
|
||||
return arrayOf(
|
||||
@@ -101,45 +111,51 @@ class PackageBridge : V8Package {
|
||||
}
|
||||
|
||||
var timeoutCounter = 0;
|
||||
var timeoutMap = HashSet<Int>();
|
||||
var timeoutMap = ConcurrentHashMap<Int, Any?>();
|
||||
@V8Function
|
||||
fun setTimeout(func: V8ValueFunction, timeout: Long): Int {
|
||||
val id = timeoutCounter++;
|
||||
|
||||
val funcClone = func.toClone<V8ValueFunction>()
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
delay(timeout);
|
||||
synchronized(timeoutMap) {
|
||||
if(!timeoutMap.contains(id)) {
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
return@launch;
|
||||
if (_plugin.isStopped)
|
||||
return@launch;
|
||||
if (!timeoutMap.containsKey(id)) {
|
||||
_plugin.busy {
|
||||
if (!_plugin.isStopped)
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
}
|
||||
timeoutMap.remove(id);
|
||||
return@launch;
|
||||
}
|
||||
timeoutMap.remove(id);
|
||||
try {
|
||||
_plugin.whenNotBusy {
|
||||
funcClone.callVoid(null, arrayOf<Any>());
|
||||
_plugin.busy {
|
||||
if (!_plugin.isStopped)
|
||||
funcClone.callVoid(null, arrayOf<Any>());
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed timeout callback", ex);
|
||||
}
|
||||
finally {
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
} finally {
|
||||
_plugin.busy {
|
||||
if (!_plugin.isStopped)
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
}
|
||||
//_plugin.whenNotBusy {
|
||||
//}
|
||||
}
|
||||
};
|
||||
synchronized(timeoutMap) {
|
||||
timeoutMap.add(id);
|
||||
}
|
||||
timeoutMap.put(id, true);
|
||||
return id;
|
||||
}
|
||||
@V8Function
|
||||
fun clearTimeout(id: Int) {
|
||||
synchronized(timeoutMap) {
|
||||
if(timeoutMap.contains(id))
|
||||
timeoutMap.remove(id);
|
||||
}
|
||||
if (timeoutMap.containsKey(id))
|
||||
timeoutMap.remove(id);
|
||||
}
|
||||
@V8Function
|
||||
fun sleep(length: Int) {
|
||||
Thread.sleep(length.toLong());
|
||||
}
|
||||
|
||||
@V8Function
|
||||
@@ -147,7 +163,7 @@ class PackageBridge : V8Package {
|
||||
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
UIDialogs.toast(str);
|
||||
UIDialogs.appToast(str);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show toast.", e);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,17 @@ class PackageHttp: V8Package {
|
||||
private val aliveSockets = mutableListOf<SocketResult>();
|
||||
private var _cleanedUp = false;
|
||||
|
||||
private val _clients = mutableMapOf<String, PackageHttpClient>()
|
||||
|
||||
fun getClient(id: String?): PackageHttpClient {
|
||||
if(id == null)
|
||||
throw IllegalArgumentException("Http client ${id} doesn't exist");
|
||||
if(_packageClient.clientId() == id)
|
||||
return _packageClient;
|
||||
if(_packageClientAuth.clientId() == id)
|
||||
return _packageClientAuth;
|
||||
return _clients.getOrDefault(id, null) ?: throw IllegalArgumentException("Http client ${id} doesn't exist");
|
||||
}
|
||||
|
||||
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
|
||||
_config = config;
|
||||
@@ -112,6 +123,8 @@ class PackageHttp: V8Package {
|
||||
_plugin.registerHttpClient(httpClient);
|
||||
val client = PackageHttpClient(this, httpClient);
|
||||
|
||||
_clients.put(client.clientId() ?: "", client);
|
||||
|
||||
return client;
|
||||
}
|
||||
@V8Function
|
||||
@@ -246,18 +259,18 @@ class PackageHttp: V8Package {
|
||||
|
||||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder {
|
||||
return clientRequest(_package.getDefaultClient(useAuth), method, url, headers);
|
||||
return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers);
|
||||
}
|
||||
@V8Function
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder {
|
||||
return clientRequestWithBody(_package.getDefaultClient(useAuth), method, url, body, headers);
|
||||
return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers);
|
||||
}
|
||||
@V8Function
|
||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder
|
||||
= clientGET(_package.getDefaultClient(useAuth), url, headers);
|
||||
= clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers);
|
||||
@V8Function
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder
|
||||
= clientPOST(_package.getDefaultClient(useAuth), url, body, headers);
|
||||
= clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers);
|
||||
|
||||
@V8Function
|
||||
fun DUMMY(): BatchBuilder {
|
||||
@@ -268,21 +281,21 @@ class PackageHttp: V8Package {
|
||||
//Client-specific
|
||||
|
||||
@V8Function
|
||||
fun clientRequest(client: PackageHttpClient, method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder {
|
||||
_reqs.add(Pair(client, RequestDescriptor(method, url, headers)));
|
||||
fun clientRequest(clientId: String?, method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder {
|
||||
_reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers)));
|
||||
return BatchBuilder(_package, _reqs);
|
||||
}
|
||||
@V8Function
|
||||
fun clientRequestWithBody(client: PackageHttpClient, method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder {
|
||||
_reqs.add(Pair(client, RequestDescriptor(method, url, headers, body)));
|
||||
fun clientRequestWithBody(clientId: String?, method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder {
|
||||
_reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers, body)));
|
||||
return BatchBuilder(_package, _reqs);
|
||||
}
|
||||
@V8Function
|
||||
fun clientGET(client: PackageHttpClient, url: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder
|
||||
= clientRequest(client, "GET", url, headers);
|
||||
fun clientGET(clientId: String?, url: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder
|
||||
= clientRequest(clientId, "GET", url, headers);
|
||||
@V8Function
|
||||
fun clientPOST(client: PackageHttpClient, url: String, body: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder
|
||||
= clientRequestWithBody(client, "POST", url, body, headers);
|
||||
fun clientPOST(clientId: String?, url: String, body: String, headers: MutableMap<String, String> = HashMap()) : BatchBuilder
|
||||
= clientRequestWithBody(clientId, "POST", url, body, headers);
|
||||
|
||||
|
||||
//Finalizer
|
||||
@@ -321,6 +334,7 @@ class PackageHttp: V8Package {
|
||||
@Transient
|
||||
private val _clientId: String?;
|
||||
|
||||
|
||||
@V8Property
|
||||
fun clientId(): String? {
|
||||
return _clientId;
|
||||
@@ -333,6 +347,17 @@ class PackageHttp: V8Package {
|
||||
_clientId = if(_client is JSHttpClient) _client.clientId else null;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun resetAuthCookies(){
|
||||
if(_client is JSHttpClient)
|
||||
_client.resetAuthCookies();
|
||||
}
|
||||
@V8Function
|
||||
fun clearOtherCookies(){
|
||||
if(_client is JSHttpClient)
|
||||
_client.clearOtherCookies();
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun setDefaultHeaders(defaultHeaders: Map<String, String>) {
|
||||
for(pair in defaultHeaders)
|
||||
@@ -429,8 +454,23 @@ class PackageHttp: V8Package {
|
||||
};
|
||||
}
|
||||
@V8Function
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse {
|
||||
if(body is V8ValueString)
|
||||
return POSTInternal(url, body.value, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is String)
|
||||
return POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is V8ValueTypedArray)
|
||||
return POSTInternal(url, body.toBytes(), headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is ByteArray)
|
||||
return POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is ArrayList<*>) //Avoid this case, used purely for testing
|
||||
return POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
else
|
||||
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
||||
}
|
||||
|
||||
|
||||
// = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
@@ -452,9 +492,6 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
};
|
||||
}
|
||||
@V8Function
|
||||
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
@@ -630,7 +667,9 @@ class PackageHttp: V8Package {
|
||||
_isOpen = true;
|
||||
if(hasOpen && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
|
||||
@@ -640,7 +679,9 @@ class PackageHttp: V8Package {
|
||||
override fun message(msg: String) {
|
||||
if(hasMessage && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
}
|
||||
@@ -649,7 +690,9 @@ class PackageHttp: V8Package {
|
||||
if(hasClosing && _listeners?.isClosed != true)
|
||||
{
|
||||
try {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
|
||||
@@ -660,7 +703,9 @@ class PackageHttp: V8Package {
|
||||
_isOpen = false;
|
||||
if(hasClosed && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
|
||||
@@ -676,7 +721,9 @@ class PackageHttp: V8Package {
|
||||
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
||||
if(hasFailure && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
|
||||
|
||||
+2
@@ -778,6 +778,8 @@ class ArticleDetailFragment : MainFragment {
|
||||
view.onAddToWatchLaterClicked.subscribe { a ->
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
else
|
||||
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
|
||||
}
|
||||
}
|
||||
else if(content is IPlatformPost) {
|
||||
|
||||
+3
-1
@@ -172,7 +172,7 @@ class ChannelFragment : MainFragment() {
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe)
|
||||
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
|
||||
_overlayLoading = findViewById(R.id.channel_loading_overlay)
|
||||
_overlayLoadingSpinner = findViewById(R.id.channel_loader)
|
||||
_overlayLoadingSpinner = findViewById(R.id.channel_loader_frag)
|
||||
_overlayContainer = findViewById(R.id.overlay_container)
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
|
||||
@@ -226,6 +226,8 @@ class ChannelFragment : MainFragment() {
|
||||
if (content is IPlatformVideo) {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
else
|
||||
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
|
||||
}
|
||||
}
|
||||
adapter.onUrlClicked.subscribe { url ->
|
||||
|
||||
+2
@@ -86,6 +86,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
if(it is IPlatformVideo) {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
else
|
||||
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
|
||||
}
|
||||
};
|
||||
adapter.onLongPress.subscribe(this) {
|
||||
|
||||
+23
-12
@@ -10,7 +10,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -165,14 +164,24 @@ class PlaylistFragment : MainFragment() {
|
||||
};
|
||||
}
|
||||
|
||||
private fun copyPlaylist(playlist: Playlist) {
|
||||
private fun savePlaylist(playlist: Playlist) {
|
||||
StatePlaylists.instance.playlistStore.save(playlist)
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
|
||||
arrayListOf()
|
||||
)
|
||||
UIDialogs.toast("Playlist saved")
|
||||
}
|
||||
|
||||
private fun copyPlaylist(playlist: Playlist) {
|
||||
var copyNumber = 1
|
||||
var newName = "${playlist.name} (Copy)"
|
||||
val playlists = StatePlaylists.instance.playlistStore.getItems()
|
||||
while (playlists.any { it.name == newName }) {
|
||||
copyNumber += 1
|
||||
newName = "${playlist.name} (Copy $copyNumber)"
|
||||
}
|
||||
StatePlaylists.instance.playlistStore.save(playlist.makeCopy(newName))
|
||||
_fragment.navigate<PlaylistsFragment>(withHistory = false)
|
||||
UIDialogs.toast("Playlist copied")
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?) {
|
||||
_taskLoadPlaylist.cancel()
|
||||
|
||||
@@ -188,12 +197,14 @@ class PlaylistFragment : MainFragment() {
|
||||
setButtonExportVisible(false)
|
||||
setButtonEditVisible(true)
|
||||
|
||||
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()
|
||||
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()
|
||||
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||
if (StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
|
||||
copyPlaylist(parameter)
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
savePlaylist(parameter)
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
setName(null)
|
||||
setVideos(null, false)
|
||||
@@ -259,7 +270,7 @@ class PlaylistFragment : MainFragment() {
|
||||
val playlist = _playlist ?: return
|
||||
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
|
||||
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", {
|
||||
copyPlaylist(playlist)
|
||||
savePlaylist(playlist)
|
||||
download()
|
||||
})
|
||||
return
|
||||
@@ -292,7 +303,7 @@ class PlaylistFragment : MainFragment() {
|
||||
val playlist = _playlist ?: return
|
||||
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
|
||||
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", {
|
||||
copyPlaylist(playlist)
|
||||
savePlaylist(playlist)
|
||||
onEditClick()
|
||||
})
|
||||
return
|
||||
|
||||
+1
-6
@@ -101,7 +101,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
}
|
||||
|
||||
private fun isSmallWindow(): Boolean {
|
||||
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2
|
||||
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.smallest_width_dp)
|
||||
}
|
||||
|
||||
private fun isAutoRotateEnabled(): Boolean {
|
||||
@@ -627,11 +627,6 @@ class VideoDetailFragment() : MainFragment() {
|
||||
showSystemUI()
|
||||
}
|
||||
|
||||
// temporarily force the device to portrait if auto-rotate is disabled to prevent landscape when exiting full screen on a small device
|
||||
// @SuppressLint("SourceLockedOrientationActivity")
|
||||
// if (!isFullscreen && isSmallWindow() && !isAutoRotateEnabled() && !isMinimizingFromFullScreen) {
|
||||
// activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||
// }
|
||||
updateOrientation();
|
||||
_view?.allowMotion = !fullscreen;
|
||||
}
|
||||
|
||||
+64
-11
@@ -2,6 +2,8 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
@@ -91,6 +93,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.fixHtmlLinks
|
||||
@@ -172,6 +175,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
@@ -408,6 +412,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
showChaptersUI();
|
||||
};
|
||||
|
||||
_title.setOnLongClickListener {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
|
||||
val clip = ClipData.newPlainText("Video Title", (it as TextView).text);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
UIDialogs.toast(context, "Copied", false)
|
||||
// let other interactions happen based on the touch
|
||||
false
|
||||
}
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
@@ -597,6 +609,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
_player.onReloadRequired.subscribe {
|
||||
fetchVideo();
|
||||
}
|
||||
|
||||
_player.onPlayChanged.subscribe {
|
||||
if (StateCasting.instance.activeDevice == null) {
|
||||
handlePlayChanged(it);
|
||||
@@ -1399,8 +1415,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
onVideoChanged.emit(0, 0)
|
||||
}
|
||||
|
||||
val me = this;
|
||||
if (video is JSVideoDetails) {
|
||||
val me = this;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
//TODO: Implement video.getContentChapters()
|
||||
@@ -1457,6 +1473,32 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!StateApp.instance.privateMode) {
|
||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||
var tracker = video.getPlaybackTracker()
|
||||
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
|
||||
|
||||
if (tracker == null) {
|
||||
stopwatch.reset()
|
||||
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
|
||||
Logger.i(
|
||||
TAG,
|
||||
"StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms"
|
||||
)
|
||||
}
|
||||
|
||||
if (me.video?.url == video.url && !video.url.isNullOrBlank())
|
||||
me._playbackTracker = tracker;
|
||||
} else if (me.video == video)
|
||||
me._playbackTracker = null;
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Playback tracker failed", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||
val extraBytesRef = video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
@@ -1897,8 +1939,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
updateQualityFormatsOverlay(
|
||||
videoTrackFormats.distinctBy { it.height }.sortedBy { it.height },
|
||||
audioTrackFormats.distinctBy { it.bitrate }.sortedBy { it.bitrate });
|
||||
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
|
||||
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2156,19 +2198,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||
val format = if(playbackSpeeds.size < 20) "%.2f" else "%.1f";
|
||||
val playbackLabels = playbackSpeeds.map { String.format(format, it) }.toMutableList();
|
||||
val playbackLabels = playbackSpeeds.map { String.format(Locale.US, format, it) }.toMutableList();
|
||||
playbackLabels.add("+");
|
||||
playbackLabels.add(0, "-");
|
||||
|
||||
setButtons(playbackLabels, String.format(format, currentPlaybackRate));
|
||||
setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate));
|
||||
onClick.subscribe { v ->
|
||||
val currentPlaybackSpeed = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate();
|
||||
var playbackSpeedString = v;
|
||||
val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep();
|
||||
if(v == "+")
|
||||
playbackSpeedString = String.format("%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString();
|
||||
playbackSpeedString = String.format(Locale.US, "%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString();
|
||||
else if(v == "-")
|
||||
playbackSpeedString = String.format("%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString();
|
||||
playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString();
|
||||
val newPlaybackSpeed = playbackSpeedString.toDouble();
|
||||
if (_isCasting) {
|
||||
val ad = StateCasting.instance.activeDevice ?: return@subscribe
|
||||
@@ -2176,11 +2218,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", newPlaybackSpeed)})");
|
||||
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
|
||||
ad.changeSpeed(newPlaybackSpeed)
|
||||
setSelected(playbackSpeedString);
|
||||
} else {
|
||||
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", newPlaybackSpeed)})");
|
||||
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
|
||||
_player.setPlaybackRate(playbackSpeedString.toFloat());
|
||||
setSelected(playbackSpeedString);
|
||||
}
|
||||
@@ -2455,7 +2497,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val url = _url;
|
||||
if (!url.isNullOrBlank()) {
|
||||
setLoading(true);
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
setLoading(true);
|
||||
}
|
||||
_taskLoadVideo.run(url);
|
||||
}
|
||||
}
|
||||
@@ -2539,7 +2583,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
_player.gestureControl.saveBrightness()
|
||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||
_player.gestureControl.saveBrightness()
|
||||
}
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
_player.gestureControl.restoreBrightness()
|
||||
@@ -2719,6 +2765,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(it is IPlatformVideo) {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
else
|
||||
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
|
||||
}
|
||||
}
|
||||
onAddToQueueClicked.subscribe(this) {
|
||||
@@ -2986,6 +3034,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
return@TaskHandler result;
|
||||
})
|
||||
.success { setVideoDetails(it, true) }
|
||||
.exception<ScriptReloadRequiredException> {
|
||||
StatePlatform.instance.handleReloadRequired(it, {
|
||||
fetchVideo();
|
||||
});
|
||||
}
|
||||
.exception<NoPlatformClientException> {
|
||||
Logger.w(TAG, "exception<NoPlatformClientException>", it)
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@ import com.futo.platformplayer.casting.CastProtocolType
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class CastingDeviceInfo {
|
||||
var name: String;
|
||||
var type: CastProtocolType;
|
||||
var addresses: Array<String>;
|
||||
var port: Int;
|
||||
var name: String
|
||||
var type: CastProtocolType
|
||||
var addresses: Array<String>
|
||||
var port: Int
|
||||
|
||||
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
this.name = name
|
||||
this.type = type
|
||||
this.addresses = addresses
|
||||
this.port = port
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -35,11 +36,15 @@ class Playlist {
|
||||
this.videos = ArrayList(list);
|
||||
}
|
||||
|
||||
fun makeCopy(newName: String? = null): Playlist {
|
||||
return Playlist(newName ?: name, videos)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject?): Playlist? {
|
||||
if(obj == null)
|
||||
return null;
|
||||
obj.ensureIsBusy();
|
||||
|
||||
val contextName = "Playlist";
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class DownloadService : Service() {
|
||||
Logger.i(TAG, "onStartCommand");
|
||||
synchronized(this) {
|
||||
if(_started)
|
||||
return START_STICKY;
|
||||
return START_NOT_STICKY;
|
||||
|
||||
if(!FragmentedStorage.isInitialized) {
|
||||
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.LruCache
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -38,6 +39,7 @@ import com.futo.platformplayer.awaitFirstNotNullDeferred
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.fromPool
|
||||
import com.futo.platformplayer.getNowDiffDays
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
@@ -316,7 +318,18 @@ class StatePlatform {
|
||||
_platformOrderPersistent.save();
|
||||
}
|
||||
|
||||
suspend fun reloadClient(context: Context, id: String) : JSClient? {
|
||||
fun handleReloadRequired(reloadRequiredException: ScriptReloadRequiredException, afterReload: (() -> Unit)? = null) {
|
||||
val id = if(reloadRequiredException.config is SourcePluginConfig) reloadRequiredException.config.id else "";
|
||||
UIDialogs.appToast("Reloading [${reloadRequiredException.config.name}] by plugin request");
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(!reloadRequiredException.reloadData.isNullOrEmpty())
|
||||
reEnableClientWithData(id, reloadRequiredException.reloadData, afterReload);
|
||||
else
|
||||
reEnableClient(id, afterReload);
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reloadClient(context: Context, id: String, afterReload: (()->Unit)? = null) : JSClient? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val client = getClient(id);
|
||||
if (client !is JSClient)
|
||||
@@ -347,10 +360,27 @@ class StatePlatform {
|
||||
_availableClients.removeIf { it.id == id };
|
||||
_availableClients.add(newClient);
|
||||
}
|
||||
afterReload?.invoke();
|
||||
return@withContext newClient;
|
||||
};
|
||||
}
|
||||
|
||||
suspend fun reEnableClientWithData(id: String, data: String? = null, afterReload: (()->Unit)? = null) {
|
||||
val enabledBefore = getEnabledClients().map { it.id };
|
||||
if(data != null) {
|
||||
val client = getClientOrNull(id);
|
||||
if(client != null && client is JSClient)
|
||||
client.setReloadData(data);
|
||||
}
|
||||
selectClients({
|
||||
_scope.launch(Dispatchers.IO) {
|
||||
selectClients({
|
||||
afterReload?.invoke();
|
||||
}, *(enabledBefore).distinct().toTypedArray());
|
||||
}
|
||||
}, *(enabledBefore.filter { it != id }).distinct().toTypedArray())
|
||||
}
|
||||
suspend fun reEnableClient(id: String, afterReload: (()->Unit)? = null) = reEnableClientWithData(id, null, afterReload);
|
||||
|
||||
suspend fun enableClient(ids: List<String>) {
|
||||
val currentClients = getEnabledClients().map { it.id };
|
||||
@@ -361,6 +391,9 @@ class StatePlatform {
|
||||
* If a client is disabled, NO requests are made to said client
|
||||
*/
|
||||
suspend fun selectClients(vararg ids: String) {
|
||||
selectClients(null, *ids);
|
||||
}
|
||||
suspend fun selectClients(afterLoad: (() -> Unit)?, vararg ids: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
synchronized(_clientsLock) {
|
||||
val removed = _enabledClients.toMutableList();
|
||||
@@ -385,6 +418,7 @@ class StatePlatform {
|
||||
onSourceDisabled.emit(oldClient);
|
||||
}
|
||||
}
|
||||
afterLoad?.invoke();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -935,7 +969,7 @@ class StatePlatform {
|
||||
return EmptyPager();
|
||||
|
||||
if(!StateApp.instance.privateMode)
|
||||
return client.fromPool(_mainClientPool).getComments(url);
|
||||
return client.fromPool(_pagerClientPool).getComments(url);
|
||||
else
|
||||
return client.fromPool(_privateClientPool).getComments(url);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.services.MediaPlaybackService
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -113,6 +117,8 @@ class StatePlayer {
|
||||
var currentVideo: IPlatformVideoDetails? = null
|
||||
private set;
|
||||
|
||||
private val _lastQueue = FragmentedStorage.storeJson<SerializedPlatformVideo>("lastQueue").load();
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
|
||||
currentVideo = video;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.futo.platformplayer.states
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
@@ -21,7 +20,6 @@ import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||
@@ -30,15 +28,12 @@ import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
||||
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@@ -178,31 +173,30 @@ class StatePlaylists {
|
||||
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
|
||||
}
|
||||
}
|
||||
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean {
|
||||
var wasNew = false;
|
||||
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false): Boolean {
|
||||
synchronized(_watchlistStore) {
|
||||
if(!_watchlistStore.hasItem { it.url == video.url })
|
||||
wasNew = true;
|
||||
_watchlistStore.saveAsync(video);
|
||||
if(orderPosition == -1)
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray());
|
||||
else {
|
||||
val existing = _watchlistOrderStore.getAllValues().toMutableList();
|
||||
existing.add(orderPosition, video.url);
|
||||
_watchlistOrderStore.set(*existing.toTypedArray());
|
||||
if (_watchlistStore.hasItem { it.url == video.url }) {
|
||||
return false
|
||||
}
|
||||
_watchlistOrderStore.save();
|
||||
|
||||
_watchlistStore.saveAsync(video)
|
||||
if (Settings.instance.other.watchLaterAddStart) {
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray())
|
||||
} else {
|
||||
_watchlistOrderStore.set(*(_watchlistOrderStore.values + listOf(video.url)).toTypedArray())
|
||||
}
|
||||
_watchlistOrderStore.save()
|
||||
}
|
||||
onWatchLaterChanged.emit();
|
||||
|
||||
if(isUserInteraction) {
|
||||
if (isUserInteraction) {
|
||||
val now = OffsetDateTime.now();
|
||||
_watchLaterAdds.setAndSave(video.url, now);
|
||||
broadcastWatchLaterAddition(video, now);
|
||||
}
|
||||
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
return wasNew;
|
||||
return true;
|
||||
}
|
||||
|
||||
fun getLastPlayedPlaylist() : Playlist? {
|
||||
|
||||
@@ -78,7 +78,13 @@ class StateSync {
|
||||
onAuthorized = { sess, isNewlyAuthorized, isNewSession ->
|
||||
if (isNewSession) {
|
||||
deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess)
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) }
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
checkForSync(sess)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to check for sync.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
|
||||
if (resolve != null) {
|
||||
resolveCount = resolves.size;
|
||||
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}")
|
||||
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
|
||||
for(result in resolve){
|
||||
val task = providedTasks?.find { it.url == result.channelUrl };
|
||||
if(task != null) {
|
||||
|
||||
@@ -4,21 +4,20 @@ import android.graphics.drawable.Animatable
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.AirPlay2CastingDevice
|
||||
|
||||
class DeviceViewHolder : ViewHolder {
|
||||
private val _layoutDevice: FrameLayout;
|
||||
@@ -84,9 +83,12 @@ class DeviceViewHolder : ViewHolder {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
} else if (d is AirPlay1CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is AirPlay2CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.airplay_audio_logo);
|
||||
_textType.text = "AirPlay 2";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FCast";
|
||||
|
||||
+7
-1
@@ -95,7 +95,13 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
.into(_imageThumbnail);
|
||||
_textName.text = v.name;
|
||||
_textAuthor.text = v.author.name;
|
||||
_textVideoDuration.text = v.duration.toHumanTime(false);
|
||||
|
||||
if(v.duration > 0) {
|
||||
_textVideoDuration.text = v.duration.toHumanTime(false);
|
||||
_textVideoDuration.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_textVideoDuration.visibility = View.GONE;
|
||||
|
||||
val historyPosition = StateHistory.instance.getHistoryPosition(v.url)
|
||||
_timeBar.progress = historyPosition.toFloat() / v.duration.toFloat();
|
||||
|
||||
+8
-2
@@ -204,8 +204,14 @@ open class PreviewVideoView : LinearLayout {
|
||||
.into(_imageVideo);
|
||||
};
|
||||
|
||||
if(!isPlanned)
|
||||
_textVideoDuration.text = video.duration.toHumanTime(false);
|
||||
if(!isPlanned) {
|
||||
if(video.duration > 0) {
|
||||
_textVideoDuration.text = video.duration.toHumanTime(false);
|
||||
_textVideoDuration.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_textVideoDuration.visibility = View.GONE;
|
||||
}
|
||||
else
|
||||
_textVideoDuration.text = context.getString(R.string.planned);
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
class GestureControlView : LinearLayout {
|
||||
@@ -79,6 +82,9 @@ class GestureControlView : LinearLayout {
|
||||
private var _adjustingFullscreenDown: Boolean = false;
|
||||
private var _fullScreenFactorUp = 1.0f;
|
||||
private var _fullScreenFactorDown = 1.0f;
|
||||
private val _layoutHoldSpeed: LinearLayout
|
||||
private val _textHoldFastForward: TextView
|
||||
private val _imageHoldFastForward: ImageView
|
||||
|
||||
private var _scaleGestureDetector: ScaleGestureDetector
|
||||
private var _scaleFactor = 1.0f
|
||||
@@ -92,6 +98,11 @@ class GestureControlView : LinearLayout {
|
||||
private var _surfaceView: View? = null
|
||||
private var _layoutIndicatorFill: FrameLayout;
|
||||
private var _layoutIndicatorFit: FrameLayout;
|
||||
private var _speedHolding = false
|
||||
|
||||
private val _speedFormatter = DecimalFormat("#.##", DecimalFormatSymbols(Locale.US)).apply {
|
||||
roundingMode = java.math.RoundingMode.HALF_UP
|
||||
}
|
||||
|
||||
private val _gestureController: GestureDetectorCompat;
|
||||
|
||||
@@ -103,6 +114,8 @@ class GestureControlView : LinearLayout {
|
||||
val onZoom = Event1<Float>();
|
||||
val onSoundAdjusted = Event1<Float>();
|
||||
val onToggleFullscreen = Event0();
|
||||
val onSpeedHoldStart = Event0()
|
||||
val onSpeedHoldEnd = Event0()
|
||||
|
||||
var fullScreenGestureEnabled = true
|
||||
|
||||
@@ -124,6 +137,9 @@ class GestureControlView : LinearLayout {
|
||||
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
|
||||
_layoutIndicatorFill = findViewById(R.id.layout_indicator_fill);
|
||||
_layoutIndicatorFit = findViewById(R.id.layout_indicator_fit);
|
||||
_layoutHoldSpeed = findViewById(R.id.layout_controls_increased_speed)
|
||||
_textHoldFastForward = findViewById(R.id.text_holdFastForward)
|
||||
_imageHoldFastForward = findViewById(R.id.image_holdFastForward)
|
||||
|
||||
_scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
@@ -216,7 +232,21 @@ class GestureControlView : LinearLayout {
|
||||
|
||||
return true;
|
||||
}
|
||||
override fun onLongPress(p0: MotionEvent) = Unit
|
||||
override fun onLongPress(p0: MotionEvent) {
|
||||
if (!_isControlsLocked
|
||||
&& !_skipping
|
||||
&& !_adjustingBrightness
|
||||
&& !_adjustingSound
|
||||
&& !_adjustingFullscreenUp
|
||||
&& !_adjustingFullscreenDown
|
||||
&& !_isPanning
|
||||
&& !_isZooming
|
||||
&& Settings.instance.playback.getHoldPlaybackSpeed() > 1.0) {
|
||||
_speedHolding = true
|
||||
showHoldSpeedControls()
|
||||
onSpeedHoldStart.emit()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
|
||||
@@ -301,6 +331,17 @@ class GestureControlView : LinearLayout {
|
||||
onPan.emit(_translationX, _translationY)
|
||||
}
|
||||
|
||||
private fun showHoldSpeedControls() {
|
||||
_layoutHoldSpeed.visibility = View.VISIBLE
|
||||
_textHoldFastForward.text = _speedFormatter.format(Settings.instance.playback.getHoldPlaybackSpeed()) + "x"
|
||||
(_imageHoldFastForward.drawable as? Animatable)?.start()
|
||||
}
|
||||
|
||||
private fun hideHoldSpeedControls() {
|
||||
_layoutHoldSpeed.visibility = View.GONE
|
||||
(_imageHoldFastForward.drawable as? Animatable)?.stop()
|
||||
}
|
||||
|
||||
fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
|
||||
_layoutControls = layoutControls;
|
||||
_background = background;
|
||||
@@ -309,6 +350,12 @@ class GestureControlView : LinearLayout {
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
val ev = event ?: return super.onTouchEvent(event);
|
||||
|
||||
if (ev.action == MotionEvent.ACTION_UP && _speedHolding) {
|
||||
_speedHolding = false
|
||||
hideHoldSpeedControls()
|
||||
onSpeedHoldEnd.emit()
|
||||
}
|
||||
|
||||
cancelHideJob();
|
||||
|
||||
if (_skipping) {
|
||||
|
||||
@@ -18,9 +18,10 @@ import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
@@ -58,6 +59,8 @@ class CastView : ConstraintLayout {
|
||||
private var _inPictureInPicture: Boolean = false;
|
||||
private var _chapters: List<IChapter>? = null;
|
||||
private var _currentChapter: IChapter? = null;
|
||||
private var _speedHoldPrevRate = 1.0
|
||||
private var _speedHoldWasPlaying = false
|
||||
|
||||
val onChapterChanged = Event2<IChapter?, Boolean>();
|
||||
val onMinimizeClick = Event0();
|
||||
@@ -87,6 +90,20 @@ class CastView : ConstraintLayout {
|
||||
_gestureControlView = findViewById(R.id.gesture_control);
|
||||
_gestureControlView.fullScreenGestureEnabled = false
|
||||
_gestureControlView.setupTouchArea();
|
||||
_gestureControlView.onSpeedHoldStart.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
_speedHoldWasPlaying = d.isPlaying
|
||||
_speedHoldPrevRate = d.speed
|
||||
if (d.canSetSpeed)
|
||||
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
|
||||
d.resumeVideo()
|
||||
}
|
||||
_gestureControlView.onSpeedHoldEnd.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
if (!_speedHoldWasPlaying) d.pauseVideo()
|
||||
d.changeSpeed(_speedHoldPrevRate)
|
||||
}
|
||||
|
||||
_gestureControlView.onSeek.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
|
||||
@@ -193,7 +210,7 @@ class CastView : ConstraintLayout {
|
||||
|
||||
if(isPlaying) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
|
||||
if (d is AirPlay1CastingDevice || d is ChromecastCastingDevice) {
|
||||
_updateTimeJob = _scope.launch {
|
||||
while (true) {
|
||||
val device = StateCasting.instance.activeDevice;
|
||||
|
||||
+15
-11
@@ -13,6 +13,7 @@ import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
|
||||
@@ -42,10 +43,14 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>, hideButtons: Boolean = false): super(context){
|
||||
init(animated, okText);
|
||||
_container = parent;
|
||||
if(!_container!!.children.contains(this)) {
|
||||
_container!!.removeAllViews();
|
||||
_container!!.addView(this);
|
||||
_container!!.removeAllViews();
|
||||
_container!!.addView(this);
|
||||
if (_container!!.isVisible) {
|
||||
isVisible = true
|
||||
_viewBackground.alpha = 1.0f;
|
||||
_viewOverlayContainer.translationY = 0.0f;
|
||||
}
|
||||
|
||||
_textTitle.text = titleText;
|
||||
groupItems = items;
|
||||
|
||||
@@ -56,6 +61,12 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
}
|
||||
|
||||
setItems(items);
|
||||
|
||||
if (!isVisible) {
|
||||
_viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
|
||||
_viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat()
|
||||
_viewBackground.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,16 +157,9 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
}
|
||||
|
||||
isVisible = true;
|
||||
_container?.post {
|
||||
_container?.visibility = View.VISIBLE;
|
||||
_container?.bringToFront();
|
||||
}
|
||||
_container?.visibility = View.VISIBLE;
|
||||
|
||||
if (_animated) {
|
||||
_viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
|
||||
_viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat()
|
||||
_viewBackground.alpha = 0f;
|
||||
|
||||
val animations = arrayListOf<Animator>();
|
||||
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(ANIMATION_DURATION_MS));
|
||||
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(ANIMATION_DURATION_MS));
|
||||
|
||||
@@ -117,6 +117,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
private var _isControlsLocked: Boolean = false;
|
||||
|
||||
private var _speedHoldPrevRate = 1f
|
||||
private var _speedHoldWasPlaying = false
|
||||
|
||||
private val _time_bar_listener: TimeBar.OnScrubListener;
|
||||
|
||||
var isFitMode : Boolean = false
|
||||
@@ -254,6 +257,20 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
gestureControl = findViewById(R.id.gesture_control);
|
||||
|
||||
gestureControl.setupTouchArea(_layoutControls, background);
|
||||
gestureControl.onSpeedHoldStart.subscribe {
|
||||
exoPlayer?.player?.let { player ->
|
||||
_speedHoldWasPlaying = player.isPlaying
|
||||
_speedHoldPrevRate = getPlaybackRate()
|
||||
setPlaybackRate(Settings.instance.playback.getHoldPlaybackSpeed().toFloat())
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
gestureControl.onSpeedHoldEnd.subscribe {
|
||||
exoPlayer?.player?.let { player ->
|
||||
if (!_speedHoldWasPlaying) player.pause()
|
||||
setPlaybackRate(_speedHoldPrevRate)
|
||||
}
|
||||
}
|
||||
gestureControl.onSeek.subscribe { seekFromCurrent(it); };
|
||||
gestureControl.onSoundAdjusted.subscribe {
|
||||
if (Settings.instance.gestureControls.useSystemVolume) {
|
||||
|
||||
@@ -52,10 +52,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.views.video.datasources.PluginMediaDrmCallback
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
@@ -108,6 +111,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
val onPositionDiscontinuity = Event1<Long>();
|
||||
val onDatasourceError = Event1<Throwable>();
|
||||
|
||||
val onReloadRequired = Event0();
|
||||
|
||||
private var _didCallSourceChange = false;
|
||||
private var _lastState: Int = -1;
|
||||
|
||||
@@ -348,8 +353,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
var videoSourceUsed = videoSource;
|
||||
var audioSourceUsed = audioSource;
|
||||
if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
|
||||
audioSourceUsed = null;
|
||||
videoSource.getUnderlyingPlugin()?.busy {
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
|
||||
audioSourceUsed = null;
|
||||
}
|
||||
}
|
||||
|
||||
val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
@@ -560,17 +567,20 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
|
||||
if(videoSource.hasGenerate) {
|
||||
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
|
||||
var startId = -1;
|
||||
try {
|
||||
val generated = videoSource.generate();
|
||||
val plugin = videoSource.getUnderlyingPlugin() ?: return@launch;
|
||||
startId = plugin.getUnderlyingPlugin()?.runtimeId ?: -1;
|
||||
val generated = plugin.busy { videoSource.generate(); };
|
||||
if (generated != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
|
||||
videoSource.getHttpDataSourceFactory()
|
||||
withContext(Dispatchers.IO) { videoSource.getHttpDataSourceFactory() }
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
|
||||
if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource)
|
||||
dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor());
|
||||
dataSource.setRequestExecutor2(withContext(Dispatchers.IO){videoSource.audio.getRequestExecutor()});
|
||||
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
|
||||
.createMediaSource(
|
||||
DashManifestParser().parse(
|
||||
@@ -585,6 +595,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(reloadRequired: ScriptReloadRequiredException) {
|
||||
Logger.i(TAG, "Reload required detected");
|
||||
val plugin = videoSource.getUnderlyingPlugin();
|
||||
if(plugin == null)
|
||||
return@launch;
|
||||
if(startId != -1 && plugin.getUnderlyingPlugin()?.runtimeId != startId)
|
||||
return@launch;
|
||||
StatePlatform.instance.handleReloadRequired(reloadRequired, {
|
||||
onReloadRequired.emit();
|
||||
});
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "DashRaw generator failed", ex);
|
||||
}
|
||||
@@ -671,25 +692,47 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean {
|
||||
Logger.i(TAG, "Loading AudioSource [DashRaw]");
|
||||
val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
if(audioSource.hasGenerate) {
|
||||
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
|
||||
val generated = audioSource.generate();
|
||||
if(generated != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
|
||||
.createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url),
|
||||
ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0))));
|
||||
loadSelectedSources(play, resume);
|
||||
var startId = -1;
|
||||
try {
|
||||
startId = audioSource.getUnderlyingPlugin()?.getUnderlyingPlugin()?.runtimeId ?: -1;
|
||||
val generated = audioSource.generate();
|
||||
if(generated != null) {
|
||||
val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
withContext(Dispatchers.Main) {
|
||||
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
|
||||
.createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url),
|
||||
ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0))));
|
||||
loadSelectedSources(play, resume);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(reloadRequired: ScriptReloadRequiredException) {
|
||||
Logger.i(TAG, "Reload required detected");
|
||||
val plugin = audioSource.getUnderlyingPlugin();
|
||||
if(plugin == null)
|
||||
return@launch;
|
||||
if(startId != -1 && plugin.getUnderlyingPlugin()?.runtimeId != startId)
|
||||
return@launch;
|
||||
StatePlatform.instance.reEnableClient(plugin.id, {
|
||||
onReloadRequired.emit();
|
||||
});
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
else
|
||||
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
|
||||
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
|
||||
.createMediaSource(
|
||||
DashManifestParser().parse(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:viewportWidth="46"
|
||||
android:viewportHeight="46"
|
||||
android:width="46dp"
|
||||
android:height="46dp">
|
||||
<path
|
||||
android:pathData="M22.24 28.66L8.63 44.35c-0.56 0.65 -0.1 1.66 0.76 1.66h27.22c0.86 0 1.32 -1.01 0.76 -1.66L23.76 28.66a0.999 0.999 0 0 0 -1.51 0Z"
|
||||
android:fillColor="@android:color/white" />
|
||||
<path
|
||||
android:pathData="M15 23c0 -4.41 3.59 -8 8 -8s8 3.59 8 8c0 2.64 -1.29 4.97 -3.26 6.43l1.64 1.89c2.5 -1.92 4.12 -4.93 4.12 -8.33 0 -5.8 -4.7 -10.5 -10.5 -10.5s-10.5 4.7 -10.5 10.5c0 3.4 1.62 6.41 4.12 8.33l1.64 -1.89C16.29 27.97 15 25.64 15 23Z"
|
||||
android:fillColor="@android:color/white" />
|
||||
<path
|
||||
android:pathData="M9 23c0 -7.72 6.28 -14 14 -14s14 6.28 14 14c0 4.44 -2.09 8.4 -5.33 10.97l1.65 1.9c3.77 -3.02 6.18 -7.66 6.18 -12.86 0 -9.11 -7.39 -16.5 -16.5 -16.5S6.5 13.89 6.5 23c0 5.21 2.42 9.84 6.18 12.86l1.65 -1.9C11.09 31.39 9 27.44 9 22.99Z"
|
||||
android:fillColor="@android:color/white" />
|
||||
<path
|
||||
android:pathData="M2.5 23C2.5 11.7 11.7 2.5 23 2.5S43.5 11.7 43.5 23c0 6.4 -2.95 12.12 -7.56 15.88l1.65 1.9C42.73 36.56 46 30.16 46 23 46 10.3 35.7 0 23 0S0 10.3 0 23c0 7.17 3.28 13.56 8.41 17.78l1.65 -1.9C5.45 35.12 2.5 29.4 2.5 23Z"
|
||||
android:fillColor="@android:color/white" />
|
||||
</vector>
|
||||
@@ -76,4 +76,15 @@
|
||||
app:buttonIcon="@drawable/ic_copy"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_loader"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_instructions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Please enter the pairing code displayed on your device. If no PIN is required, tap cancel."
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_pairing_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Pairing Code"
|
||||
android:inputType="text"
|
||||
android:singleLine="true"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_error"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/pastel_red"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="5dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Cancel"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/colorPrimary"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:padding="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_submit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:layout_marginStart="28dp"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Submit"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -173,7 +173,7 @@
|
||||
android:background="#77000000"
|
||||
android:gravity="center">
|
||||
<ImageView
|
||||
android:id="@+id/channel_loader"
|
||||
android:id="@+id/channel_loader_frag"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="10dp"
|
||||
android:animateLayoutChanges="true">
|
||||
<ScrollView
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
@@ -152,13 +152,14 @@
|
||||
android:id="@+id/button_add_sources"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
app:buttonIcon="@drawable/ic_explore"
|
||||
app:buttonText="Add Sources"
|
||||
app:buttonSubText="Install new sources to see more content."
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
android:isScrollContainer="false"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="13sp"
|
||||
android:maxLines="100"
|
||||
android:maxLines="150"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_metadata"
|
||||
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
|
||||
@@ -116,9 +116,9 @@
|
||||
android:layout_marginBottom="6dp"
|
||||
android:background="#DD000000"
|
||||
android:visibility="gone"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/container_locked"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user