mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-26 17:55:20 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d4835a89 | |||
| 56c0f7bfaf | |||
| 736424ae35 | |||
| 37dc778009 | |||
| cd3cea58a4 | |||
| 8b53e9e5e3 | |||
| 08e98b089c | |||
| 5528d71da8 | |||
| 83f520ca44 | |||
| cc247ce634 | |||
| c6caa59a90 | |||
| 00e28b9ce0 | |||
| 334f58979a | |||
| 940bf163da | |||
| 2bbe0e6133 | |||
| 861f34a287 | |||
| 86a4cf8d84 | |||
| 2c463dd5a1 | |||
| ed3820bec0 | |||
| 542a7f212d | |||
| 8fb0826d69 | |||
| deeaa55f56 | |||
| 5b954727a1 | |||
| fae77c1a63 | |||
| b69402dfe9 | |||
| 1f3e306a59 | |||
| a9605118fb | |||
| d22e918273 | |||
| bdcb94055a | |||
| d0644d39da | |||
| 8f3f776e22 | |||
| 548752e240 |
+1
-6
@@ -173,7 +173,6 @@ 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
|
||||
@@ -206,8 +205,6 @@ 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'
|
||||
@@ -224,9 +221,7 @@ dependencies {
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
//Payment
|
||||
implementation('com.stripe:stripe-android:20.35.1') {
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15to18'
|
||||
}
|
||||
implementation 'com.stripe:stripe-android:20.35.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import toAndroidColor
|
||||
|
||||
class CSSColorTests {
|
||||
@Test
|
||||
fun test1() {
|
||||
val androidHex = "#80336699"
|
||||
val androidColorInt = Color.parseColor(androidHex)
|
||||
|
||||
val cssHex = "#33669980"
|
||||
val cssColor = CSSColor.parseColor(cssHex)
|
||||
|
||||
assertEquals(
|
||||
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||
androidColorInt,
|
||||
cssColor.toAndroidColor(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test2() {
|
||||
val androidHex = "#123ABC"
|
||||
val androidColorInt = Color.parseColor(androidHex)
|
||||
|
||||
val cssHex = "#123ABCFF"
|
||||
val cssColor = CSSColor.parseColor(cssHex)
|
||||
|
||||
assertEquals(
|
||||
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||
androidColorInt,
|
||||
cssColor.toAndroidColor()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -707,11 +707,12 @@ class LiveEventViewCount extends LiveEvent {
|
||||
}
|
||||
}
|
||||
class LiveEventRaid extends LiveEvent {
|
||||
constructor(targetUrl, targetName, targetThumbnail) {
|
||||
constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
|
||||
super(100);
|
||||
this.targetUrl = targetUrl;
|
||||
this.targetName = targetName;
|
||||
this.targetThumbnail = targetThumbnail;
|
||||
this.isOutgoing = isOutgoing ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import kotlin.math.*
|
||||
|
||||
class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) {
|
||||
init {
|
||||
require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) {
|
||||
"RGBA channels must be in [0,1]"
|
||||
}
|
||||
}
|
||||
|
||||
// -- RGB(A) channels stored 0–1 --
|
||||
var r: Float = r.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var g: Float = g.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var b: Float = b.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var a: Float = a.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f) }
|
||||
|
||||
// -- Int views of RGBA 0–255 --
|
||||
var red: Int
|
||||
get() = (r * 255).roundToInt()
|
||||
set(v) { r = (v.coerceIn(0, 255) / 255f) }
|
||||
var green: Int
|
||||
get() = (g * 255).roundToInt()
|
||||
set(v) { g = (v.coerceIn(0, 255) / 255f) }
|
||||
var blue: Int
|
||||
get() = (b * 255).roundToInt()
|
||||
set(v) { b = (v.coerceIn(0, 255) / 255f) }
|
||||
var alpha: Int
|
||||
get() = (a * 255).roundToInt()
|
||||
set(v) { a = (v.coerceIn(0, 255) / 255f) }
|
||||
|
||||
// -- HSLA storage & lazy recompute flags --
|
||||
private var _h: Float = 0f
|
||||
private var _s: Float = 0f
|
||||
private var _l: Float = 0f
|
||||
private var _hslDirty = true
|
||||
|
||||
/** Hue [0...360) */
|
||||
var hue: Float
|
||||
get() { computeHslIfNeeded(); return _h }
|
||||
set(v) { setHsl(v, saturation, lightness) }
|
||||
|
||||
/** Saturation [0...1] */
|
||||
var saturation: Float
|
||||
get() { computeHslIfNeeded(); return _s }
|
||||
set(v) { setHsl(hue, v, lightness) }
|
||||
|
||||
/** Lightness [0...1] */
|
||||
var lightness: Float
|
||||
get() { computeHslIfNeeded(); return _l }
|
||||
set(v) { setHsl(hue, saturation, v) }
|
||||
|
||||
private fun computeHslIfNeeded() {
|
||||
if (!_hslDirty) return
|
||||
val max = max(max(r, g), b)
|
||||
val min = min(min(r, g), b)
|
||||
val d = max - min
|
||||
_l = (max + min) / 2f
|
||||
_s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f))
|
||||
_h = when {
|
||||
d == 0f -> 0f
|
||||
max == r -> ((g - b) / d % 6f) * 60f
|
||||
max == g -> (((b - r) / d) + 2f) * 60f
|
||||
else -> (((r - g) / d) + 4f) * 60f
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
_hslDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all three HSL channels at once.
|
||||
* Hue in degrees [0...360), s/l [0...1].
|
||||
*/
|
||||
fun setHsl(h: Float, s: Float, l: Float) {
|
||||
val hh = ((h % 360f) + 360f) % 360f
|
||||
val cc = (1f - abs(2f * l - 1f)) * s
|
||||
val x = cc * (1f - abs((hh / 60f) % 2f - 1f))
|
||||
val m = l - cc / 2f
|
||||
|
||||
val (rp, gp, bp) = when {
|
||||
hh < 60f -> Triple(cc, x, 0f)
|
||||
hh < 120f -> Triple(x, cc, 0f)
|
||||
hh < 180f -> Triple(0f, cc, x)
|
||||
hh < 240f -> Triple(0f, x, cc)
|
||||
hh < 300f -> Triple(x, 0f, cc)
|
||||
else -> Triple(cc, 0f, x)
|
||||
}
|
||||
|
||||
r = rp + m; g = gp + m; b = bp + m
|
||||
_h = hh; _s = s; _l = l; _hslDirty = false
|
||||
}
|
||||
|
||||
/** Return 0xRRGGBBAA int */
|
||||
fun toRgbaInt(): Int {
|
||||
val ai = (a * 255).roundToInt() and 0xFF
|
||||
val ri = (r * 255).roundToInt() and 0xFF
|
||||
val gi = (g * 255).roundToInt() and 0xFF
|
||||
val bi = (b * 255).roundToInt() and 0xFF
|
||||
return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai
|
||||
}
|
||||
|
||||
/** Return 0xAARRGGBB int */
|
||||
fun toArgbInt(): Int {
|
||||
val ai = (a * 255).roundToInt() and 0xFF
|
||||
val ri = (r * 255).roundToInt() and 0xFF
|
||||
val gi = (g * 255).roundToInt() and 0xFF
|
||||
val bi = (b * 255).roundToInt() and 0xFF
|
||||
return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi
|
||||
}
|
||||
|
||||
// — Convenience modifiers (chainable) —
|
||||
|
||||
/** Lighten by fraction [0...1] */
|
||||
fun lighten(fraction: Float): CSSColor = apply {
|
||||
lightness = (lightness + fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Darken by fraction [0...1] */
|
||||
fun darken(fraction: Float): CSSColor = apply {
|
||||
lightness = (lightness - fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Increase saturation by fraction [0...1] */
|
||||
fun saturate(fraction: Float): CSSColor = apply {
|
||||
saturation = (saturation + fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Decrease saturation by fraction [0...1] */
|
||||
fun desaturate(fraction: Float): CSSColor = apply {
|
||||
saturation = (saturation - fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Rotate hue by degrees (can be negative) */
|
||||
fun rotateHue(degrees: Float): CSSColor = apply {
|
||||
hue = (hue + degrees) % 360f
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Create from Android 0xAARRGGBB */
|
||||
@JvmStatic fun fromArgb(color: Int): CSSColor {
|
||||
val a = ((color ushr 24) and 0xFF) / 255f
|
||||
val r = ((color ushr 16) and 0xFF) / 255f
|
||||
val g = ((color ushr 8) and 0xFF) / 255f
|
||||
val b = ( color and 0xFF) / 255f
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
/** Create from Android 0xRRGGBBAA */
|
||||
@JvmStatic fun fromRgba(color: Int): CSSColor {
|
||||
val r = ((color ushr 24) and 0xFF) / 255f
|
||||
val g = ((color ushr 16) and 0xFF) / 255f
|
||||
val b = ((color ushr 8) and 0xFF) / 255f
|
||||
val a = ( color and 0xFF) / 255f
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
@JvmStatic fun fromAndroidColor(color: Int): CSSColor {
|
||||
return fromArgb(color)
|
||||
}
|
||||
|
||||
private val NAMED_HEX = mapOf(
|
||||
"aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF",
|
||||
"aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC",
|
||||
"bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD",
|
||||
"blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A",
|
||||
"burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00",
|
||||
"chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED",
|
||||
"cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF",
|
||||
"darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B",
|
||||
"darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9",
|
||||
"darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F",
|
||||
"darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000",
|
||||
"darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B",
|
||||
"darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1",
|
||||
"darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF",
|
||||
"dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF",
|
||||
"firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22",
|
||||
"fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF",
|
||||
"gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080",
|
||||
"green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080",
|
||||
"honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C",
|
||||
"indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C",
|
||||
"lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00",
|
||||
"lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080",
|
||||
"lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3",
|
||||
"lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1",
|
||||
"lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA",
|
||||
"lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE",
|
||||
"lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32",
|
||||
"linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000",
|
||||
"mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3",
|
||||
"mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE",
|
||||
"mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585",
|
||||
"midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1",
|
||||
"moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080",
|
||||
"oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23",
|
||||
"orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6",
|
||||
"palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE",
|
||||
"palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9",
|
||||
"peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD",
|
||||
"powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399",
|
||||
"red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1",
|
||||
"saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460",
|
||||
"seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D",
|
||||
"silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD",
|
||||
"slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA",
|
||||
"springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C",
|
||||
"teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347",
|
||||
"turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3",
|
||||
"white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00",
|
||||
"yellowgreen" to "9ACD32"
|
||||
)
|
||||
private val NAMED: Map<String, Int> = NAMED_HEX
|
||||
.mapValues { (_, hexRgb) ->
|
||||
// parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity
|
||||
val rgb = hexRgb.toInt(16)
|
||||
(rgb shl 8) or 0xFF
|
||||
} + ("transparent" to 0x00000000)
|
||||
|
||||
private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE)
|
||||
private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||
private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||
|
||||
@JvmStatic
|
||||
fun parseColor(s: String): CSSColor {
|
||||
val str = s.trim()
|
||||
// named
|
||||
NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() }
|
||||
|
||||
// hex
|
||||
HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part ->
|
||||
return parseHexPart(part)
|
||||
}
|
||||
|
||||
// rgb/rgba
|
||||
RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||
return parseRgbParts(it.split(',').map(String::trim))
|
||||
}
|
||||
|
||||
// hsl/hsla
|
||||
HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||
return parseHslParts(it.split(',').map(String::trim))
|
||||
}
|
||||
|
||||
error("Cannot parse color: \"$s\"")
|
||||
}
|
||||
|
||||
private fun parseHexPart(p: String): CSSColor {
|
||||
// expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA"
|
||||
val hex = when (p.length) {
|
||||
3 -> p.map { "$it$it" }.joinToString("") + "FF"
|
||||
4 -> p.map { "$it$it" }.joinToString("")
|
||||
6 -> p + "FF"
|
||||
8 -> p
|
||||
else -> error("Invalid hex color: #$p")
|
||||
}
|
||||
|
||||
val parsed = hex.toLong(16).toInt()
|
||||
val alpha = (parsed and 0xFF) shl 24
|
||||
val rgbOnly = (parsed ushr 8) and 0x00FFFFFF
|
||||
val argb = alpha or rgbOnly
|
||||
return fromArgb(argb)
|
||||
}
|
||||
|
||||
private fun parseRgbParts(parts: List<String>): CSSColor {
|
||||
require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" }
|
||||
|
||||
// r/g/b: "128" → 128/255, "50%" → 0.5
|
||||
fun channel(ch: String): Float =
|
||||
if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f
|
||||
else ch.toFloat().coerceIn(0f, 255f) / 255f
|
||||
|
||||
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||
fun alpha(a: String): Float =
|
||||
if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f
|
||||
else a.toFloat().coerceIn(0f, 1f)
|
||||
|
||||
val r = channel(parts[0])
|
||||
val g = channel(parts[1])
|
||||
val b = channel(parts[2])
|
||||
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
private fun parseHslParts(parts: List<String>): CSSColor {
|
||||
require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" }
|
||||
|
||||
fun hueOf(h: String): Float = when {
|
||||
h.endsWith("deg") -> h.removeSuffix("deg").toFloat()
|
||||
h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f
|
||||
h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat())
|
||||
h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f
|
||||
else -> h.toFloat()
|
||||
}
|
||||
|
||||
// for s and l you only ever see percentages
|
||||
fun pct(p: String): Float =
|
||||
p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f
|
||||
|
||||
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||
fun alpha(a: String): Float =
|
||||
if (a.endsWith("%")) pct(a)
|
||||
else a.toFloat().coerceIn(0f, 1f)
|
||||
|
||||
val h = hueOf(parts[0])
|
||||
val s = pct(parts[1])
|
||||
val l = pct(parts[2])
|
||||
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||
|
||||
return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this)
|
||||
fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this)
|
||||
fun CSSColor.toAndroidColor(): Int = toArgbInt()
|
||||
@@ -69,16 +69,4 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,30 @@ package com.futo.platformplayer
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.*
|
||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueError
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.selects.SelectClause0
|
||||
import kotlinx.coroutines.selects.SelectClause1
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType
|
||||
|
||||
|
||||
//V8
|
||||
@@ -174,4 +192,137 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
||||
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
||||
map.put(prop, obj.getString(prop));
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
val latch = CountDownLatch(1);
|
||||
var promiseResult: T? = null;
|
||||
var promiseException: Throwable? = null;
|
||||
plugin.busy {
|
||||
this.register(object: IV8ValuePromise.IListener {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
if(p0 is V8ValueError)
|
||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||
else
|
||||
promiseResult = p0 as T;
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onRejected promise not implemented.."));
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onCatch promise not implemented.."));
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
plugin.registerPromise(this) {
|
||||
promiseException = CancellationException("Cancelled by system");
|
||||
latch.countDown();
|
||||
}
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
}
|
||||
if(promiseException != null)
|
||||
throw promiseException!!;
|
||||
return promiseResult!!;
|
||||
}
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T> {
|
||||
val underlyingDef = CompletableDeferred<T>();
|
||||
val def = if(this.has("estDuration"))
|
||||
V8Deferred(underlyingDef,
|
||||
this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1);
|
||||
else
|
||||
V8Deferred<T>(underlyingDef);
|
||||
|
||||
if(def.estDuration > 0)
|
||||
Logger.i("V8", "Promise with duration: [${def.estDuration}]");
|
||||
|
||||
val promise = this;
|
||||
plugin.busy {
|
||||
this.register(object: IV8ValuePromise.IListener {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.complete(p0 as T);
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
||||
}
|
||||
});
|
||||
}
|
||||
plugin.registerPromise(promise) {
|
||||
if(def.isActive)
|
||||
def.cancel("Cancelled by system");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||
|
||||
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||
val newDef = CompletableDeferred<R>()
|
||||
this.invokeOnCompletion {
|
||||
if(it != null)
|
||||
newDef.completeExceptionally(it);
|
||||
else
|
||||
newDef.complete(conversion(this@V8Deferred.getCompleted()));
|
||||
}
|
||||
|
||||
return V8Deferred<R>(newDef, estDuration);
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun <T, R> merge(scope: CoroutineScope, defs: List<V8Deferred<T>>, conversion: (result: List<T>)->R): V8Deferred<R> {
|
||||
|
||||
var amount = -1;
|
||||
for(def in defs)
|
||||
amount = Math.max(amount, def.estDuration);
|
||||
|
||||
val def = scope.async {
|
||||
val results = defs.map { it.await() };
|
||||
return@async conversion(results);
|
||||
}
|
||||
return V8Deferred(def, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueAsync(this.getSourcePlugin()!!);
|
||||
}
|
||||
return V8Deferred(CompletableDeferred(result as T));
|
||||
}
|
||||
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
|
||||
return result;
|
||||
}
|
||||
return V8Deferred(CompletableDeferred(result));
|
||||
}
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -464,14 +463,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.whenStateAtLeast
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
@@ -114,7 +113,6 @@ import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
@@ -610,6 +608,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}
|
||||
|
||||
//startActivity(Intent(this, TestActivity::class.java))
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TestActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_test);
|
||||
|
||||
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
|
||||
view.startLoader(10000)
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(5000)
|
||||
view.startLoader()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -34,8 +34,10 @@ class PlatformClientPool {
|
||||
isDead = true;
|
||||
onDead.emit(parentClient, this);
|
||||
|
||||
for(clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
synchronized(_pool) {
|
||||
for (clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventEmojis"
|
||||
return LiveEventEmojis(
|
||||
obj.getOrThrow(config, "emojis", contextName));
|
||||
return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,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.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventRaid: IPlatformLiveEvent {
|
||||
@@ -11,11 +12,13 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
val targetName: String;
|
||||
val targetThumbnail: String;
|
||||
val targetUrl: String;
|
||||
val isOutgoing: Boolean;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String) {
|
||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||
this.targetName = name;
|
||||
this.targetUrl = url;
|
||||
this.targetThumbnail = thumbnail;
|
||||
this.isOutgoing = isOutgoing;
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -25,7 +28,8 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
return LiveEventRaid(
|
||||
obj.getOrThrow(config, "targetName", contextName),
|
||||
obj.getOrThrow(config, "targetUrl", contextName),
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName));
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName),
|
||||
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,7 +632,6 @@ open class JSClient : IPlatformClient {
|
||||
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
|
||||
@JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page")
|
||||
@JSDocsParameter("url", "Url of content")
|
||||
override fun getContentRecommendations(url: String): IPager<IPlatformContent>? = isBusyWith("getContentRecommendations") {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
abstract class JSPager<T> : IPager<T> {
|
||||
@@ -49,7 +50,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
pager.invokeV8("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
|
||||
+4
-3
@@ -6,6 +6,7 @@ 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.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
@@ -57,7 +58,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
_client.busy {
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeVoid("onInit", seconds);
|
||||
_obj.invokeV8Void("onInit", seconds);
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
@@ -73,7 +74,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
else {
|
||||
_client.busy {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying);
|
||||
_obj.invokeV8Void("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
}
|
||||
@@ -86,7 +87,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
synchronized(_obj) {
|
||||
Logger.i("JSPlaybackTracker", "onConcluded");
|
||||
_client.busy {
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
_obj.invokeV8Void("onConcluded", -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -14,6 +14,8 @@ import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -55,7 +57,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
@@ -63,7 +65,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
|
||||
try {
|
||||
@@ -110,7 +112,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
_executor.invokeV8("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
@@ -118,7 +120,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
_executor.invokeV8("cleanup", null);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -11,6 +11,8 @@ import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSRequestModifier: IRequestModifier {
|
||||
private val _plugin: JSClient;
|
||||
@@ -40,7 +42,7 @@ class JSRequestModifier: IRequestModifier {
|
||||
|
||||
return _plugin.busy {
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
_modifier.invokeV8("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
|
||||
+3
-1
@@ -6,6 +6,8 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
override val licenseUri: String
|
||||
@@ -25,7 +27,7 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
+47
-2
@@ -1,6 +1,8 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.V8Deferred
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
@@ -13,8 +15,13 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
override val container : String;
|
||||
@@ -50,6 +57,44 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
if(_plugin is DevJSClient)
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
}
|
||||
|
||||
return 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@busy result.convert {
|
||||
it.value
|
||||
};
|
||||
}
|
||||
}
|
||||
override fun generate(): String? {
|
||||
if(!hasGenerate)
|
||||
return manifest;
|
||||
@@ -63,14 +108,14 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeString("generate");
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeString("generate");
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-2
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.V8Deferred
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
@@ -15,11 +16,18 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
interface IJSDashManifestRawSource {
|
||||
val hasGenerate: Boolean;
|
||||
var manifest: String?;
|
||||
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
||||
fun generate(): String?;
|
||||
}
|
||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
@@ -57,6 +65,45 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
if(_plugin is DevJSClient) {
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
});
|
||||
|
||||
return 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@busy result.convert {
|
||||
it.value
|
||||
};
|
||||
}
|
||||
}
|
||||
override open fun generate(): String? {
|
||||
if(!hasGenerate)
|
||||
return manifest;
|
||||
@@ -68,7 +115,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeString("generate");
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -76,7 +123,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeString("generate");
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,6 +163,32 @@ class JSDashManifestMergingRawSource(
|
||||
override val priority: Boolean
|
||||
get() = video.priority;
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
val videoDashDef = video.generateAsync(scope);
|
||||
val audioDashDef = audio.generateAsync(scope);
|
||||
|
||||
return V8Deferred.merge(scope, listOf(videoDashDef, audioDashDef)) {
|
||||
val (videoDash: String?, audioDash: String?) = it;
|
||||
|
||||
if (videoDash != null && audioDash == null) return@merge videoDash;
|
||||
if (audioDash != null && videoDash == null) return@merge audioDash;
|
||||
if (videoDash == null) return@merge null;
|
||||
|
||||
//TODO: Temporary simple solution..make more reliable version
|
||||
|
||||
var result: String? = null;
|
||||
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
|
||||
if (audioAdaptationSet != null) {
|
||||
result = videoDash.replace(
|
||||
"</AdaptationSet>",
|
||||
"</AdaptationSet>\n" + audioAdaptationSet.value
|
||||
)
|
||||
} else
|
||||
result = videoDash;
|
||||
|
||||
return@merge result;
|
||||
};
|
||||
}
|
||||
override fun generate(): String? {
|
||||
val videoDash = video.generate();
|
||||
val audioDash = audio.generate();
|
||||
|
||||
+3
-1
@@ -9,6 +9,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
IDashManifestWidevineSource, JSSource {
|
||||
@@ -45,7 +47,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
+3
-2
@@ -16,6 +16,7 @@ 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.invokeV8
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.orNull
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
@@ -64,7 +65,7 @@ abstract class JSSource {
|
||||
return@isBusyWith null;
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||
_obj.invokeV8("getRequestModifier", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
@@ -78,7 +79,7 @@ abstract class JSSource {
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] requesting");
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
|
||||
_obj.invoke("getRequestExecutor", arrayOf<Any>());
|
||||
_obj.invokeV8("getRequestExecutor", arrayOf<Any>());
|
||||
};
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] received");
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
|
||||
class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
||||
override val licenseUri: String
|
||||
@@ -25,7 +26,7 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+125
-179
@@ -15,60 +15,55 @@ import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlay1CastingDevice : CastingDevice {
|
||||
class AirPlayCastingDevice : 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(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)")
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "loadContent called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
setTime(resumePosition)
|
||||
setDuration(duration)
|
||||
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) {
|
||||
@@ -77,157 +72,117 @@ class AirPlay1CastingDevice : 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;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "seekVideo()-> $timeSeconds")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "seekVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}")
|
||||
post("scrub?position=${timeSeconds}");
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "resumeVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "resumeVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = true
|
||||
post("rate?value=1.000000")
|
||||
isPlaying = true;
|
||||
post("rate?value=1.000000");
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "pauseVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "pauseVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = false
|
||||
post("rate?value=0.000000")
|
||||
isPlaying = false;
|
||||
post("rate?value=0.000000");
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "stopVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "stopVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("stop")
|
||||
post("stop");
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "stopCasting()")
|
||||
if (_sessionId != null) {
|
||||
post("stop")
|
||||
}
|
||||
stop()
|
||||
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) {
|
||||
Logger.i(TAG, "Unable to connect yet; retrying in 1s.")
|
||||
delay(1000)
|
||||
continue
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
_sessionId = UUID.randomUUID().toString();
|
||||
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)
|
||||
}
|
||||
@@ -235,111 +190,103 @@ class AirPlay1CastingDevice : 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;
|
||||
|
||||
_sessionId = null
|
||||
usedRemoteAddress = null
|
||||
localAddress = null
|
||||
_started = false
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = 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) {
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
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) {
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
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(
|
||||
@@ -347,38 +294,37 @@ class AirPlay1CastingDevice : 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) {
|
||||
Logger.w(TAG, "GET /$path failed (HTTP ${response.code})")
|
||||
return null
|
||||
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 = "AirPlay1CastingDevice"
|
||||
val TAG = "AirPlayCastingDevice";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -22,8 +21,7 @@ enum class CastConnectionState {
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST,
|
||||
AIRPLAY2;
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
@@ -42,29 +40,23 @@ enum class CastProtocolType {
|
||||
}
|
||||
}
|
||||
|
||||
interface IPairingDataHandler {
|
||||
fun savePairingData(deviceId: String, pairingData: ByteArray)
|
||||
fun loadPairingData(deviceId: String): ByteArray?
|
||||
fun clearPairingData(deviceId: String)
|
||||
}
|
||||
|
||||
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
|
||||
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 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
|
||||
@@ -119,42 +111,38 @@ 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 onPairingPinRequired = Event0()
|
||||
open fun providePairingPin(pin: String?) { throw NotImplementedError() }
|
||||
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 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 stopCasting();
|
||||
|
||||
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?)
|
||||
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>;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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
@@ -1,187 +0,0 @@
|
||||
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 && it.castingDevice.protocol == d.protocol }
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
@@ -161,14 +161,20 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldList[oldItemPosition].castingDevice.name == newList[newItemPosition].castingDevice.name && oldList[oldItemPosition].castingDevice.protocol == newList[newItemPosition].castingDevice.protocol
|
||||
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
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
|
||||
return oldItem == newItem
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay2CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
@@ -176,12 +175,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlay1CastingDevice) {
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_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";
|
||||
|
||||
@@ -6,12 +6,16 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Button
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.readBytes
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ImportOptionsDialog: AlertDialog {
|
||||
private val _context: MainActivity;
|
||||
@@ -41,8 +45,17 @@ class ImportOptionsDialog: AlertDialog {
|
||||
_button_import_zip.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/zip") {
|
||||
val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val zipBytes = it?.readBytes(context) ?: return@launch;
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.toast("Failed to import, invalid format?\n" + ex.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
_button_import_ezip.setOnClickListener {
|
||||
@@ -51,17 +64,35 @@ class ImportOptionsDialog: AlertDialog {
|
||||
_button_import_txt.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "text/plain") {
|
||||
val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val txt = String(txtBytes);
|
||||
StateBackup.importTxt(_context, txt);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val txtBytes = it?.readBytes(context) ?: return@launch;
|
||||
val txt = String(txtBytes);
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
StateBackup.importTxt(_context, txt);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.toast("Failed to import, invalid format?\n" + ex.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
_button_import_newpipe_subs.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/json") {
|
||||
val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val json = String(jsonBytes);
|
||||
StateBackup.importNewPipeSubs(_context, json);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val jsonBytes = it?.readBytes(context) ?: return@launch;
|
||||
val json = String(jsonBytes);
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
StateBackup.importNewPipeSubs(_context, json);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.toast("Failed to import, invalid format?\n" + ex.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
_button_import_platform.onClick.subscribe {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -37,7 +39,15 @@ import com.futo.platformplayer.engine.packages.V8Package
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import com.futo.platformplayer.toList
|
||||
import com.futo.platformplayer.toV8ValueBlocking
|
||||
import com.futo.platformplayer.toV8ValueAsync
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
@@ -48,6 +58,7 @@ class V8Plugin {
|
||||
private val _clientAuth: ManagedHttpClient;
|
||||
private val _clientOthers: ConcurrentHashMap<String, JSHttpClient> = ConcurrentHashMap();
|
||||
|
||||
private val _promises = ConcurrentHashMap<V8ValuePromise, ((V8ValuePromise)->Unit)?>();
|
||||
|
||||
val httpClient: ManagedHttpClient get() = _client;
|
||||
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
|
||||
@@ -223,37 +234,144 @@ class V8Plugin {
|
||||
Logger.i(TAG, "Plugin stopped");
|
||||
onStopped.emit(this);
|
||||
}
|
||||
cancelAllPromises();
|
||||
}
|
||||
|
||||
fun isThreadAlreadyBusy(): Boolean {
|
||||
return _busyLock.isHeldByCurrentThread;
|
||||
}
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
_busyLock.lock();
|
||||
try {
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
_busyLock.unlock();
|
||||
}
|
||||
/*
|
||||
_busyLock.withLock {
|
||||
//Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString());
|
||||
return handle();
|
||||
}*/
|
||||
}
|
||||
fun <T> unbusy(handle: ()->T): T {
|
||||
val wasLocked = isThreadAlreadyBusy();
|
||||
if(!wasLocked)
|
||||
return handle();
|
||||
val lockCount = _busyLock.holdCount;
|
||||
for(i in 1..lockCount)
|
||||
_busyLock.unlock();
|
||||
try {
|
||||
Logger.w(TAG, "Unlocking V8 thread for [${config.name}] for a blocking resolve of a promise")
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise")
|
||||
|
||||
for(i in 1..lockCount)
|
||||
_busyLock.lock();
|
||||
}
|
||||
}
|
||||
fun execute(js: String) : V8Value {
|
||||
return executeTyped<V8Value>(js);
|
||||
}
|
||||
|
||||
suspend fun <T : V8Value> executeTypedAsync(js: String) : Deferred<T> {
|
||||
warnIfMainThread("V8Plugin.executeTypedAsync");
|
||||
if(isStopped)
|
||||
throw PluginEngineStoppedException(config, "Instance is stopped", js);
|
||||
|
||||
return withContext(IO) {
|
||||
return@withContext busy {
|
||||
try {
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
val result = catchScriptErrors<V8Value>("Plugin[${config.name}]", js) {
|
||||
runtime.getExecutor(js).execute()
|
||||
};
|
||||
|
||||
if (result is V8ValuePromise) {
|
||||
return@busy result.toV8ValueAsync<T>(this@V8Plugin);
|
||||
} else
|
||||
return@busy CompletableDeferred(result as T);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
val def = CompletableDeferred<T>();
|
||||
def.completeExceptionally(ex);
|
||||
return@busy def;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun <T : V8Value> executeTyped(js: String) : T {
|
||||
warnIfMainThread("V8Plugin.executeTyped");
|
||||
if(isStopped)
|
||||
throw PluginEngineStoppedException(config, "Instance is stopped", js);
|
||||
|
||||
return busy {
|
||||
|
||||
val result = busy {
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
return@busy catchScriptErrors("Plugin[${config.name}]", js) {
|
||||
return@busy catchScriptErrors<V8Value>("Plugin[${config.name}]", js) {
|
||||
runtime.getExecutor(js).execute()
|
||||
};
|
||||
};
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueBlocking(this@V8Plugin);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
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 } }
|
||||
|
||||
|
||||
fun <T: V8Value> handlePromise(result: V8ValuePromise): CompletableDeferred<T> {
|
||||
val def = CompletableDeferred<T>();
|
||||
result.register(object: IV8ValuePromise.IListener {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
resolvePromise(result);
|
||||
def.complete(p0 as T);
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
resolvePromise(result);
|
||||
def.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
resolvePromise(result);
|
||||
def.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
||||
}
|
||||
});
|
||||
registerPromise(result) {
|
||||
if(def.isActive)
|
||||
def.cancel("Cancelled by system");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
fun registerPromise(promise: V8ValuePromise, onCancelled: ((V8ValuePromise)->Unit)? = null) {
|
||||
Logger.v(TAG, "Promise registered for plugin [${config.name}]: ${promise.hashCode()}");
|
||||
if (onCancelled != null) {
|
||||
_promises.put(promise, onCancelled)
|
||||
};
|
||||
}
|
||||
fun resolvePromise(promise: V8ValuePromise, cancelled: Boolean = false) {
|
||||
Logger.v(TAG, "Promise resolved for plugin [${config.name}]: ${promise.hashCode()}");
|
||||
val found = synchronized(_promises) {
|
||||
val found = _promises.getOrDefault(promise, null);
|
||||
_promises.remove(promise);
|
||||
return@synchronized found;
|
||||
};
|
||||
if(found != null && cancelled)
|
||||
found(promise);
|
||||
}
|
||||
fun cancelAllPromises(){
|
||||
val promises = _promises.keys().toList();
|
||||
for(key in promises) {
|
||||
try {
|
||||
resolvePromise(key, true);
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? {
|
||||
//TODO: Auto get all package types?
|
||||
return when(packageName) {
|
||||
|
||||
@@ -84,7 +84,8 @@ class PackageBridge : V8Package {
|
||||
fun supportedFeatures(): Array<String> {
|
||||
return arrayOf(
|
||||
"ReloadRequiredException",
|
||||
"HttpBatchClient"
|
||||
"HttpBatchClient",
|
||||
"Async"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,9 +131,12 @@ class PackageBridge : V8Package {
|
||||
}
|
||||
timeoutMap.remove(id);
|
||||
try {
|
||||
Logger.w(TAG, "setTimeout before busy (${timeout}): ${_plugin.isBusy}");
|
||||
_plugin.busy {
|
||||
Logger.w(TAG, "setTimeout in busy");
|
||||
if (!_plugin.isStopped)
|
||||
funcClone.callVoid(null, arrayOf<Any>());
|
||||
Logger.w(TAG, "setTimeout after");
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed timeout callback", ex);
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.internal.IV8Convertable
|
||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
@@ -668,7 +669,7 @@ class PackageHttp: V8Package {
|
||||
if(hasOpen && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
_listeners?.invokeV8Void("open", arrayOf<Any>());
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -680,7 +681,7 @@ class PackageHttp: V8Package {
|
||||
if(hasMessage && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
_listeners?.invokeV8Void("message", msg);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
@@ -691,7 +692,7 @@ class PackageHttp: V8Package {
|
||||
{
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
_listeners?.invokeV8Void("closing", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -704,7 +705,7 @@ class PackageHttp: V8Package {
|
||||
if(hasClosed && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
_listeners?.invokeV8Void("closed", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -722,7 +723,7 @@ class PackageHttp: V8Package {
|
||||
if(hasFailure && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
_listeners?.invokeV8Void("failure", exception.message);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
|
||||
+10
-3
@@ -806,6 +806,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_lastVideoSource = null;
|
||||
_lastAudioSource = null;
|
||||
_lastSubtitleSource = null;
|
||||
_cast.cancel()
|
||||
StateCasting.instance.cancel()
|
||||
video = null;
|
||||
_container_content_liveChat?.close();
|
||||
_player.clear();
|
||||
@@ -1899,7 +1901,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
|
||||
|
||||
if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) {
|
||||
val castSucceeded = StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
||||
_cast.setLoading(it)
|
||||
}, onLoadingEstimate = {
|
||||
_cast.setLoading(it)
|
||||
})
|
||||
|
||||
if (castSucceeded) {
|
||||
_cast.setVideoDetails(video, resumePositionMs / 1000);
|
||||
setCastEnabled(true);
|
||||
} else throw IllegalStateException("Disconnected cast during loading");
|
||||
@@ -2553,8 +2561,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_cast.visibility = View.VISIBLE;
|
||||
} else {
|
||||
StateCasting.instance.stopVideo();
|
||||
_cast.stopTimeJob();
|
||||
_cast.visibility = View.GONE;
|
||||
_cast.cancel()
|
||||
|
||||
if (video?.isLive == false) {
|
||||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,8 @@ 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
|
||||
|
||||
@@ -117,8 +113,6 @@ class StatePlayer {
|
||||
var currentVideo: IPlatformVideoDetails? = null
|
||||
private set;
|
||||
|
||||
private val _lastQueue = FragmentedStorage.storeJson<SerializedPlatformVideo>("lastQueue").load();
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
|
||||
currentVideo = video;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.futo.platformplayer.views
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.toColorInt
|
||||
import kotlin.math.*
|
||||
import kotlin.random.Random
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
||||
class TargetTapLoaderView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private val primaryColor = "#2D63ED".toColorInt()
|
||||
private val inactiveGlobalAlpha = 110
|
||||
private val idleSpeedMultiplier = .015f
|
||||
private val overshootInterpolator = OvershootInterpolator(1.5f)
|
||||
private val floatAccel = .03f
|
||||
private val idleMaxSpeed = .35f
|
||||
private val idleInitialTargets = 10
|
||||
private val idleHintText = "Waiting for media to become available"
|
||||
|
||||
private var expectedDurationMs: Long? = null
|
||||
private var loadStartTime = 0L
|
||||
private var playStartTime = 0L
|
||||
private var loaderFinished = false
|
||||
private var forceIndeterminate= false
|
||||
private var lastFrameTime = System.currentTimeMillis()
|
||||
|
||||
private var score = 0
|
||||
private var isPlaying = false
|
||||
|
||||
private val targets = mutableListOf<Target>()
|
||||
private val particles = mutableListOf<Particle>()
|
||||
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(0.7f, 1f, 1f, 1f)
|
||||
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics)
|
||||
textAlign = Paint.Align.LEFT
|
||||
setShadowLayer(4f, 0f, 0f, Color.BLACK)
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val idleHintPaint = Paint(textPaint).apply {
|
||||
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics)
|
||||
typeface = Typeface.DEFAULT
|
||||
setShadowLayer(2f, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryColor }
|
||||
private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = primaryColor; strokeWidth = 12f
|
||||
style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(50, 0, 0, 0) }
|
||||
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val backgroundPaint = Paint()
|
||||
private var spinnerShader: SweepGradient? = null
|
||||
private var spinnerAngle = 0f
|
||||
private val MIN_SPAWN_RATE = 1f
|
||||
private val MAX_SPAWN_RATE = 20.0f
|
||||
private val HIT_RATE_INCREMENT = 0.15f
|
||||
private val MISS_RATE_DECREMENT = 0.09f
|
||||
private var spawnRate = MIN_SPAWN_RATE
|
||||
|
||||
private val frameRunnable = object : Runnable {
|
||||
override fun run() { invalidate(); if (!loaderFinished) postDelayed(this, 16L) }
|
||||
}
|
||||
|
||||
init { setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_DOWN) handleTap(e.x, e.y); true } }
|
||||
|
||||
fun startLoader(durationMs: Long? = null) {
|
||||
val alreadyRunning = !loaderFinished
|
||||
if (alreadyRunning && durationMs == null) {
|
||||
expectedDurationMs = null
|
||||
forceIndeterminate = true
|
||||
return
|
||||
}
|
||||
|
||||
expectedDurationMs = durationMs?.takeIf { it > 0 }
|
||||
forceIndeterminate = expectedDurationMs == null
|
||||
loaderFinished = false
|
||||
isPlaying = false
|
||||
score = 0
|
||||
particles.clear()
|
||||
spawnRate = MIN_SPAWN_RATE
|
||||
|
||||
post { if (targets.isEmpty()) prepopulateIdleTargets() }
|
||||
|
||||
loadStartTime = System.currentTimeMillis()
|
||||
playStartTime = 0
|
||||
removeCallbacks(frameRunnable)
|
||||
post(frameRunnable)
|
||||
|
||||
if (!isIndeterminate) {
|
||||
postDelayed({
|
||||
if (!loaderFinished) {
|
||||
forceIndeterminate = true
|
||||
expectedDurationMs = null
|
||||
}
|
||||
}, expectedDurationMs!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun finishLoader() {
|
||||
loaderFinished = true
|
||||
particles.clear()
|
||||
isPlaying = false
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun stopAndResetLoader() {
|
||||
if (score > 0) {
|
||||
val elapsed = (System.currentTimeMillis() - (if (playStartTime > 0) playStartTime else loadStartTime)) / 1000.0
|
||||
UIDialogs.toast("Nice! score $score | ${"%.1f".format(score / elapsed)} / s")
|
||||
score = 0
|
||||
}
|
||||
loaderFinished = true
|
||||
isPlaying = false
|
||||
targets.clear()
|
||||
particles.clear()
|
||||
removeCallbacks(frameRunnable)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private val isIndeterminate get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L
|
||||
|
||||
private fun handleTap(x: Float, y: Float) {
|
||||
val idx = targets.indexOfFirst { !it.hit && hypot(x - it.x, y - it.y) <= it.radius }
|
||||
if (idx >= 0) {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
val t = targets[idx]
|
||||
t.hit = true; t.hitTime = System.currentTimeMillis()
|
||||
accelerateSpawnRate()
|
||||
score += if (!isIndeterminate) 10 else 5
|
||||
spawnParticles(t.x, t.y, t.radius)
|
||||
|
||||
if (!isPlaying) {
|
||||
isPlaying = true
|
||||
playStartTime = System.currentTimeMillis()
|
||||
score = 0
|
||||
spawnRate = MIN_SPAWN_RATE
|
||||
targets.retainAll { it === t }
|
||||
spawnTarget()
|
||||
}
|
||||
} else if (isPlaying) decelerateSpawnRate()
|
||||
}
|
||||
|
||||
private inline fun accelerateSpawnRate() {
|
||||
spawnRate = (spawnRate + HIT_RATE_INCREMENT).coerceAtMost(MAX_SPAWN_RATE)
|
||||
}
|
||||
|
||||
private inline fun decelerateSpawnRate() {
|
||||
spawnRate = (spawnRate - MISS_RATE_DECREMENT).coerceAtLeast(MIN_SPAWN_RATE)
|
||||
}
|
||||
|
||||
private fun spawnTarget() {
|
||||
if (loaderFinished || width == 0 || height == 0) {
|
||||
postDelayed({ spawnTarget() }, 200L); return
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
postDelayed({ spawnTarget() }, 500L); return
|
||||
}
|
||||
|
||||
val radius = Random.nextInt(40, 80).toFloat()
|
||||
val x = Random.nextFloat() * (width - 2 * radius) + radius
|
||||
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
|
||||
|
||||
val baseSpeed = Random.nextFloat() + .1f
|
||||
val speed = baseSpeed
|
||||
val angle = Random.nextFloat() * TAU
|
||||
val vx = cos(angle) * speed
|
||||
val vy = sin(angle) * speed
|
||||
val alpha = Random.nextInt(150, 255)
|
||||
|
||||
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
|
||||
|
||||
val delay = (1000f / spawnRate).roundToLong()
|
||||
postDelayed({ spawnTarget() }, delay)
|
||||
}
|
||||
|
||||
private fun prepopulateIdleTargets() {
|
||||
if (width == 0 || height == 0) {
|
||||
post { prepopulateIdleTargets() }
|
||||
return
|
||||
}
|
||||
repeat(idleInitialTargets) {
|
||||
val radius = Random.nextInt(40, 80).toFloat()
|
||||
val x = Random.nextFloat() * (width - 2 * radius) + radius
|
||||
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
|
||||
val angle = Random.nextFloat() * TAU
|
||||
val speed = (Random.nextFloat() * .3f + .05f) * idleSpeedMultiplier
|
||||
val vx = cos(angle) * speed
|
||||
val vy = sin(angle) * speed
|
||||
val alpha = Random.nextInt(60, 110)
|
||||
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnParticles(cx: Float, cy: Float, radius: Float) {
|
||||
repeat(12) {
|
||||
val angle = Random.nextFloat() * TAU
|
||||
val speed = Random.nextFloat() * 5f + 2f
|
||||
val vx = cos(angle) * speed
|
||||
val vy = sin(angle) * speed
|
||||
val col = ColorUtils.setAlphaComponent(primaryColor, Random.nextInt(120, 255))
|
||||
particles += Particle(cx, cy, vx, vy, System.currentTimeMillis(), col)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val deltaMs = now - lastFrameTime
|
||||
lastFrameTime = now
|
||||
|
||||
drawBackground(canvas)
|
||||
drawTargets(canvas, now)
|
||||
drawParticles(canvas, now)
|
||||
|
||||
if (!loaderFinished) {
|
||||
if (isIndeterminate) drawIndeterminateSpinner(canvas, deltaMs)
|
||||
else drawDeterministicProgressBar(canvas, now)
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
val margin = 24f
|
||||
val scoreTxt = "Score: $score"
|
||||
val speedTxt = "Speed: ${"%.2f".format(spawnRate)}/s"
|
||||
val maxWidth = width - margin
|
||||
val needRight = max(textPaint.measureText(scoreTxt), textPaint.measureText(speedTxt)) > maxWidth
|
||||
|
||||
val alignX = if (needRight) (width - margin) else margin
|
||||
textPaint.textAlign = if (needRight) Paint.Align.RIGHT else Paint.Align.LEFT
|
||||
|
||||
canvas.drawText(scoreTxt, alignX, textPaint.textSize + margin, textPaint)
|
||||
canvas.drawText(speedTxt, alignX, 2*textPaint.textSize + margin + 4f, textPaint)
|
||||
textPaint.textAlign = Paint.Align.LEFT
|
||||
}
|
||||
else if (loaderFinished)
|
||||
canvas.drawText("Loading Complete!", width/2f, height/2f, textPaint.apply { textAlign = Paint.Align.CENTER })
|
||||
else {
|
||||
idleHintPaint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText(idleHintText, width / 2f, height - 48f, idleHintPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBackground(canvas: Canvas) {
|
||||
val colors = intArrayOf(
|
||||
Color.rgb(20, 20, 40),
|
||||
Color.rgb(15, 15, 30),
|
||||
Color.rgb(10, 10, 20),
|
||||
Color.rgb( 5, 5, 10),
|
||||
Color.BLACK
|
||||
)
|
||||
val pos = floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f)
|
||||
|
||||
if (backgroundPaint.shader == null) {
|
||||
backgroundPaint.shader = LinearGradient(
|
||||
0f, 0f, 0f, height.toFloat(),
|
||||
colors, pos,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)
|
||||
}
|
||||
|
||||
private fun drawTargets(canvas: Canvas, now: Long) {
|
||||
val expireMsActive = if (isIndeterminate) 2500L else 1500L
|
||||
val it = targets.iterator()
|
||||
while (it.hasNext()) {
|
||||
val t = it.next()
|
||||
if (t.hit && now - t.hitTime > 300L) { it.remove(); continue }
|
||||
if (isPlaying && !t.hit && now - t.spawnTime > expireMsActive) {
|
||||
it.remove(); decelerateSpawnRate(); continue
|
||||
}
|
||||
t.x += t.vx; t.y += t.vy
|
||||
t.vx += (Random.nextFloat() - .5f) * floatAccel
|
||||
t.vy += (Random.nextFloat() - .5f) * floatAccel
|
||||
val speedCap = if (isPlaying) Float.MAX_VALUE else idleMaxSpeed
|
||||
val mag = hypot(t.vx, t.vy)
|
||||
if (mag > speedCap) {
|
||||
val s = speedCap / mag
|
||||
t.vx *= s; t.vy *= s
|
||||
}
|
||||
if (t.x - t.radius < 0 || t.x + t.radius > width) t.vx *= -1
|
||||
if (t.y - t.radius < 0 || t.y + t.radius > height) t.vy *= -1
|
||||
val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f,1f)
|
||||
else {
|
||||
val e = now - t.spawnAnimStart
|
||||
if (e < 300L) overshootInterpolator.getInterpolation(e/300f)
|
||||
else 1f + .02f * sin(((now - t.spawnAnimStart)/1000f)*TAU + t.pulseOffset)
|
||||
}
|
||||
val animAlpha = if (t.hit) ((1f - scale)*255).toInt() else 255
|
||||
val globalAlpha = if (isPlaying) 255 else inactiveGlobalAlpha
|
||||
val alpha = (animAlpha * t.baseAlpha /255f * globalAlpha/255f).toInt().coerceAtMost(255)
|
||||
val r = max(1f, t.radius*scale)
|
||||
val outerCol = ColorUtils.setAlphaComponent(primaryColor, alpha)
|
||||
val midCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.7f).toInt())
|
||||
val innerCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.4f).toInt())
|
||||
outerRingPaint.color = outerCol; middleRingPaint.color = midCol; centerDotPaint.color = innerCol
|
||||
|
||||
glowPaint.shader = RadialGradient(t.x, t.y, r, outerCol, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
|
||||
canvas.drawCircle(t.x, t.y, r*1.2f, glowPaint)
|
||||
canvas.drawCircle(t.x+4f, t.y+4f, r, shadowPaint)
|
||||
canvas.drawCircle(t.x, t.y, r, outerRingPaint)
|
||||
canvas.drawCircle(t.x, t.y, r*.66f, middleRingPaint)
|
||||
canvas.drawCircle(t.x, t.y, r*.33f, centerDotPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawParticles(canvas: Canvas, now: Long) {
|
||||
val lifespan = 400L
|
||||
val it = particles.iterator()
|
||||
while (it.hasNext()) {
|
||||
val p = it.next()
|
||||
val age = now - p.startTime
|
||||
if (age > lifespan) { it.remove(); continue }
|
||||
val a = ((1f - age/lifespan.toFloat())*255).toInt()
|
||||
particlePaint.color = ColorUtils.setAlphaComponent(p.baseColor, a)
|
||||
p.x += p.vx; p.y += p.vy
|
||||
canvas.drawCircle(p.x, p.y, 6f, particlePaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) {
|
||||
val dur = expectedDurationMs ?: return
|
||||
val prog = ((now - loadStartTime) / dur.toFloat()).coerceIn(0f, 1f)
|
||||
val eased = AccelerateDecelerateInterpolator().getInterpolation(prog)
|
||||
val h = 20f; val r=10f
|
||||
canvas.drawRoundRect(RectF(0f, height-h, width*eased, height.toFloat()), r, r, progressBarPaint)
|
||||
}
|
||||
|
||||
private fun drawIndeterminateSpinner(canvas: Canvas, dt: Long) {
|
||||
val cx=width/2f; val cy=height/2f; val r=min(width,height)/6f
|
||||
spinnerAngle = (spinnerAngle + .25f*dt)%360f
|
||||
if(spinnerShader == null) spinnerShader = SweepGradient(cx,cy,intArrayOf(Color.TRANSPARENT,Color.WHITE,Color.TRANSPARENT),floatArrayOf(0f,.5f,1f))
|
||||
spinnerPaint.shader = spinnerShader
|
||||
val glow = Paint(spinnerPaint).apply{ maskFilter = BlurMaskFilter(15f,BlurMaskFilter.Blur.SOLID) }
|
||||
val sweep = 270f
|
||||
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,glow)
|
||||
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,spinnerPaint)
|
||||
}
|
||||
|
||||
private data class Target(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
val radius: Float,
|
||||
val spawnTime: Long,
|
||||
var hit: Boolean = false,
|
||||
var hitTime: Long = 0L,
|
||||
val baseAlpha: Int = 255,
|
||||
var vx: Float=0f,
|
||||
var vy:Float=0f,
|
||||
val spawnAnimStart: Long = System.currentTimeMillis(),
|
||||
val pulseOffset: Float = Random.nextFloat() * TAU
|
||||
)
|
||||
private data class Particle(
|
||||
var x:Float,
|
||||
var y:Float,
|
||||
val vx:Float,
|
||||
val vy:Float,
|
||||
val startTime:Long,
|
||||
val baseColor:Int
|
||||
)
|
||||
|
||||
private companion object { private const val TAU = (2 * Math.PI).toFloat() }
|
||||
}
|
||||
@@ -4,20 +4,21 @@ 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.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
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;
|
||||
@@ -83,12 +84,9 @@ class DeviceViewHolder : ViewHolder {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlay1CastingDevice) {
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_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";
|
||||
|
||||
@@ -43,7 +43,6 @@ class HistoryListViewHolder : ViewHolder {
|
||||
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false)) {
|
||||
_root = itemView.findViewById(R.id.root);
|
||||
_imageThumbnail = itemView.findViewById(R.id.image_video_thumbnail);
|
||||
_imageThumbnail.clipToOutline = true;
|
||||
_textName = itemView.findViewById(R.id.text_video_name);
|
||||
_textAuthor = itemView.findViewById(R.id.text_author);
|
||||
_textMetadata = itemView.findViewById(R.id.text_video_metadata);
|
||||
|
||||
@@ -51,7 +51,6 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
|
||||
_root = view.findViewById(R.id.root);
|
||||
_imageThumbnail = view.findViewById(R.id.image_video_thumbnail);
|
||||
_imageThumbnail?.clipToOutline = true;
|
||||
_textName = view.findViewById(R.id.text_video_name);
|
||||
_textAuthor = view.findViewById(R.id.text_author);
|
||||
_textMetadata = view.findViewById(R.id.text_video_metadata);
|
||||
|
||||
-1
@@ -29,7 +29,6 @@ class VideoListHorizontalViewHolder : ViewHolder {
|
||||
constructor(view: View) : super(view) {
|
||||
_root = view.findViewById(R.id.root);
|
||||
_imageThumbnail = view.findViewById(R.id.image_video_thumbnail);
|
||||
_imageThumbnail?.clipToOutline = true;
|
||||
_textName = view.findViewById(R.id.text_video_name);
|
||||
_textAuthor = view.findViewById(R.id.text_author);
|
||||
_textVideoDuration = view.findViewById(R.id.thumbnail_duration);
|
||||
|
||||
@@ -21,7 +21,7 @@ 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.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
@@ -30,6 +30,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.formatDuration
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -54,6 +55,7 @@ class CastView : ConstraintLayout {
|
||||
private val _timeBar: DefaultTimeBar;
|
||||
private val _background: FrameLayout;
|
||||
private val _gestureControlView: GestureControlView;
|
||||
private val _loaderGame: TargetTapLoaderView
|
||||
private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main);
|
||||
private var _updateTimeJob: Job? = null;
|
||||
private var _inPictureInPicture: Boolean = false;
|
||||
@@ -88,6 +90,9 @@ class CastView : ConstraintLayout {
|
||||
_timeBar = findViewById(R.id.time_progress);
|
||||
_background = findViewById(R.id.layout_background);
|
||||
_gestureControlView = findViewById(R.id.gesture_control);
|
||||
_loaderGame = findViewById(R.id.loader_overlay)
|
||||
_loaderGame.visibility = View.GONE
|
||||
|
||||
_gestureControlView.fullScreenGestureEnabled = false
|
||||
_gestureControlView.setupTouchArea();
|
||||
_gestureControlView.onSpeedHoldStart.subscribe {
|
||||
@@ -197,6 +202,12 @@ class CastView : ConstraintLayout {
|
||||
_updateTimeJob = null;
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
stopTimeJob()
|
||||
setLoading(false)
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
fun stopAllGestures() {
|
||||
_gestureControlView.stopAllGestures();
|
||||
}
|
||||
@@ -210,7 +221,7 @@ class CastView : ConstraintLayout {
|
||||
|
||||
if(isPlaying) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d is AirPlay1CastingDevice || d is ChromecastCastingDevice) {
|
||||
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
|
||||
_updateTimeJob = _scope.launch {
|
||||
while (true) {
|
||||
val device = StateCasting.instance.activeDevice;
|
||||
@@ -279,6 +290,7 @@ class CastView : ConstraintLayout {
|
||||
_textDuration.text = (video.duration * 1000).formatDuration();
|
||||
_timeBar.setPosition(position);
|
||||
_timeBar.setDuration(video.duration);
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -295,6 +307,7 @@ class CastView : ConstraintLayout {
|
||||
_updateTimeJob?.cancel();
|
||||
_updateTimeJob = null;
|
||||
_scope.cancel();
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
private fun getPlaybackStateCompat(): Int {
|
||||
@@ -305,4 +318,19 @@ class CastView : ConstraintLayout {
|
||||
else -> PlaybackStateCompat.STATE_PAUSED;
|
||||
}
|
||||
}
|
||||
|
||||
fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader()
|
||||
} else {
|
||||
_loaderGame.visibility = View.GONE
|
||||
_loaderGame.stopAndResetLoader()
|
||||
}
|
||||
}
|
||||
|
||||
fun setLoading(expectedDurationMs: Int) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.views.livechat
|
||||
|
||||
import CSSColor
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.LevelListDrawable
|
||||
import android.text.Spannable
|
||||
@@ -24,6 +25,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import toAndroidColor
|
||||
|
||||
class LiveChatDonationListItem(viewGroup: ViewGroup)
|
||||
: LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) {
|
||||
@@ -55,10 +57,10 @@ class LiveChatDonationListItem(viewGroup: ViewGroup)
|
||||
_amount.text = event.amount.trim();
|
||||
|
||||
if(event.colorDonation != null && event.colorDonation.isHexColor()) {
|
||||
val color = Color.parseColor(event.colorDonation);
|
||||
_amountContainer.background.setTint(color);
|
||||
val color = CSSColor.parseColor(event.colorDonation);
|
||||
_amountContainer.background.setTint(color.toAndroidColor());
|
||||
|
||||
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400)
|
||||
if(color.lightness > 0.5)
|
||||
_amount.setTextColor(Color.BLACK);
|
||||
else
|
||||
_amount.setTextColor(Color.WHITE);
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventDonation
|
||||
import com.futo.platformplayer.isHexColor
|
||||
import toAndroidColor
|
||||
|
||||
class LiveChatDonationPill: LinearLayout {
|
||||
private val _imageAuthor: ImageView;
|
||||
@@ -33,10 +34,10 @@ class LiveChatDonationPill: LinearLayout {
|
||||
|
||||
|
||||
if(donation.colorDonation != null && donation.colorDonation.isHexColor()) {
|
||||
val color = Color.parseColor(donation.colorDonation);
|
||||
root.background.setTint(color);
|
||||
val color = CSSColor.parseColor(donation.colorDonation);
|
||||
root.background.setTint(color.toAndroidColor());
|
||||
|
||||
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400)
|
||||
if(color.lightness > 0.5)
|
||||
_textAmount.setTextColor(Color.BLACK);
|
||||
else
|
||||
_textAmount.setTextColor(Color.WHITE);
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import toAndroidColor
|
||||
|
||||
class LiveChatMessageListItem(viewGroup: ViewGroup)
|
||||
: LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) {
|
||||
@@ -75,7 +76,7 @@ class LiveChatMessageListItem(viewGroup: ViewGroup)
|
||||
|
||||
if (!event.colorName.isNullOrEmpty()) {
|
||||
try {
|
||||
_authorName.setTextColor(Color.parseColor(event.colorName));
|
||||
_authorName.setTextColor(CSSColor.parseColor(event.colorName).toAndroidColor());
|
||||
} catch (ex: Throwable) {
|
||||
}
|
||||
} else
|
||||
|
||||
@@ -14,9 +14,6 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -43,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import toAndroidColor
|
||||
|
||||
|
||||
class LiveChatOverlay : LinearLayout {
|
||||
@@ -66,10 +64,11 @@ class LiveChatOverlay : LinearLayout {
|
||||
|
||||
private val _overlayRaid: ConstraintLayout;
|
||||
private val _overlayRaid_Name: TextView;
|
||||
private val _overlayRaid_Message: TextView;
|
||||
private val _overlayRaid_Thumbnail: ImageView;
|
||||
|
||||
private val _overlayRaid_ButtonGo: Button;
|
||||
private val _overlayRaid_ButtonPrevent: Button;
|
||||
private val _overlayRaid_ButtonDismiss: Button;
|
||||
|
||||
private val _textViewers: TextView;
|
||||
|
||||
@@ -148,9 +147,10 @@ class LiveChatOverlay : LinearLayout {
|
||||
|
||||
_overlayRaid = findViewById(R.id.overlay_raid);
|
||||
_overlayRaid_Name = findViewById(R.id.raid_name);
|
||||
_overlayRaid_Message = findViewById(R.id.textRaidMessage);
|
||||
_overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail);
|
||||
_overlayRaid_ButtonGo = findViewById(R.id.raid_button_go);
|
||||
_overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent);
|
||||
_overlayRaid_ButtonDismiss = findViewById(R.id.raid_button_prevent);
|
||||
|
||||
_overlayRaid.visibility = View.GONE;
|
||||
|
||||
@@ -159,7 +159,7 @@ class LiveChatOverlay : LinearLayout {
|
||||
onRaidNow.emit(it);
|
||||
}
|
||||
}
|
||||
_overlayRaid_ButtonPrevent.setOnClickListener {
|
||||
_overlayRaid_ButtonDismiss.setOnClickListener {
|
||||
_currentRaid?.let {
|
||||
_currentRaid = null;
|
||||
_overlayRaid.visibility = View.GONE;
|
||||
@@ -291,10 +291,10 @@ class LiveChatOverlay : LinearLayout {
|
||||
_overlayDonation_Amount.text = donation.amount.trim();
|
||||
_overlayDonation.visibility = VISIBLE;
|
||||
if(donation.colorDonation != null && donation.colorDonation.isHexColor()) {
|
||||
val color = Color.parseColor(donation.colorDonation);
|
||||
_overlayDonation_AmountContainer.background.setTint(color);
|
||||
val color = CSSColor.parseColor(donation.colorDonation);
|
||||
_overlayDonation_AmountContainer.background.setTint(color.toAndroidColor());
|
||||
|
||||
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400)
|
||||
if(color.lightness > 0.5)
|
||||
_overlayDonation_Amount.setTextColor(Color.BLACK)
|
||||
else
|
||||
_overlayDonation_Amount.setTextColor(Color.WHITE);
|
||||
@@ -372,6 +372,15 @@ class LiveChatOverlay : LinearLayout {
|
||||
}
|
||||
else
|
||||
_overlayRaid.visibility = View.GONE;
|
||||
|
||||
if(raid?.isOutgoing ?: false) {
|
||||
_overlayRaid_ButtonGo.visibility = View.VISIBLE
|
||||
_overlayRaid_Message.text = "Viewers are raiding";
|
||||
}
|
||||
else {
|
||||
_overlayRaid_ButtonGo.visibility = View.GONE;
|
||||
_overlayRaid_Message.text = "Raid incoming from";
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setViewCount(viewCount: Int) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.views.video
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
@@ -43,9 +44,13 @@ import com.futo.platformplayer.formatDuration
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.Executors
|
||||
@@ -150,6 +155,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
val onChapterClicked = Event1<IChapter>();
|
||||
|
||||
private val _loaderGame: TargetTapLoaderView
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.video_view, this, true);
|
||||
@@ -190,6 +197,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
|
||||
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
|
||||
|
||||
_loaderGame = findViewById(R.id.loader_overlay)
|
||||
_loaderGame.visibility = View.GONE
|
||||
|
||||
_control_chapter.setOnClickListener {
|
||||
_currentChapter?.let {
|
||||
onChapterClicked.emit(it);
|
||||
@@ -865,4 +875,19 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
override fun onSurfaceSizeChanged(width: Int, height: Int) {
|
||||
gestureControl.resetZoomPan()
|
||||
}
|
||||
|
||||
override fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader()
|
||||
} else {
|
||||
_loaderGame.visibility = View.GONE
|
||||
_loaderGame.stopAndResetLoader()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLoading(expectedDurationMs: Int) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package com.futo.platformplayer.views.video
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
@@ -29,6 +31,8 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
@@ -52,9 +56,14 @@ 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.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -65,10 +74,13 @@ import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
import getHttpDataSourceFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
@@ -115,7 +127,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
|
||||
private var _didCallSourceChange = false;
|
||||
private var _lastState: Int = -1;
|
||||
|
||||
private val _swapIdAudio = AtomicInteger(0)
|
||||
private val _swapIdVideo = AtomicInteger(0)
|
||||
|
||||
var targetTrackVideoHeight = -1
|
||||
private set
|
||||
@@ -434,13 +447,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
|
||||
|
||||
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
|
||||
setLoading(false)
|
||||
val swapId = _swapIdVideo.incrementAndGet()
|
||||
_lastGeneratedDash = null;
|
||||
val didSet = when(videoSource) {
|
||||
is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
|
||||
is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
|
||||
is IDashManifestWidevineSource -> { swapVideoSourceDashWidevine(videoSource); true }
|
||||
is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
|
||||
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
|
||||
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume, swapId);
|
||||
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
|
||||
is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; }
|
||||
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
|
||||
@@ -451,11 +466,13 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
return didSet;
|
||||
}
|
||||
private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean {
|
||||
setLoading(false)
|
||||
val swapId = _swapIdAudio.incrementAndGet()
|
||||
val didSet = when(audioSource) {
|
||||
is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; }
|
||||
is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; }
|
||||
is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; }
|
||||
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume);
|
||||
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId);
|
||||
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
|
||||
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
|
||||
null -> { _lastAudioMediaSource = null; true; }
|
||||
@@ -562,16 +579,32 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}.createMediaSource(MediaItem.fromUri(videoSource.url))
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean {
|
||||
private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean, swapId: Int): Boolean {
|
||||
Logger.i(TAG, "Loading VideoSource [Dash]");
|
||||
|
||||
if(videoSource.hasGenerate) {
|
||||
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
|
||||
val scope = this;
|
||||
var startId = -1;
|
||||
try {
|
||||
val plugin = videoSource.getUnderlyingPlugin() ?: return@launch;
|
||||
startId = plugin.getUnderlyingPlugin()?.runtimeId ?: -1;
|
||||
val generated = plugin.busy { videoSource.generate(); };
|
||||
val generatedDef = plugin.busy { videoSource.generateAsync(scope); };
|
||||
withContext(Dispatchers.Main) {
|
||||
if (generatedDef.estDuration >= 0) {
|
||||
setLoading(generatedDef.estDuration)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
if (_swapIdVideo.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false)
|
||||
}
|
||||
if (generated != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
|
||||
@@ -608,6 +641,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "DashRaw generator failed", ex);
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -690,14 +727,30 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean {
|
||||
private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean, swapId: Int): Boolean {
|
||||
Logger.i(TAG, "Loading AudioSource [DashRaw]");
|
||||
if(audioSource.hasGenerate) {
|
||||
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
|
||||
val scope = this;
|
||||
var startId = -1;
|
||||
try {
|
||||
val plugin = audioSource.getUnderlyingPlugin() ?: return@launch;
|
||||
startId = audioSource.getUnderlyingPlugin()?.getUnderlyingPlugin()?.runtimeId ?: -1;
|
||||
val generated = audioSource.generate();
|
||||
val generatedDef = plugin.busy { audioSource.generateAsync(scope); }
|
||||
withContext(Dispatchers.Main) {
|
||||
if (generatedDef.estDuration >= 0) {
|
||||
setLoading(generatedDef.estDuration)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
if (_swapIdAudio.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false)
|
||||
}
|
||||
if(generated != null) {
|
||||
val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
|
||||
audioSource.getHttpDataSourceFactory()
|
||||
@@ -724,6 +777,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -854,6 +911,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
fun clear() {
|
||||
exoPlayer?.player?.stop();
|
||||
exoPlayer?.player?.clearMediaItems();
|
||||
setLoading(false)
|
||||
_swapIdVideo.incrementAndGet()
|
||||
_swapIdAudio.incrementAndGet()
|
||||
_lastVideoMediaSource = null;
|
||||
_lastAudioMediaSource = null;
|
||||
_lastSubtitleMediaSource = null;
|
||||
@@ -933,6 +993,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun setLoading(isLoading: Boolean) { }
|
||||
protected open fun setLoading(expectedDurationMs: Int) { }
|
||||
|
||||
companion object {
|
||||
val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:id="@android:id/background">
|
||||
<shape>
|
||||
<corners android:radius="5dip" />
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:centerColor="#ff5a5d5a"
|
||||
android:centerY="0.75"
|
||||
android:endColor="#ff5a5d5a"
|
||||
android:startColor="#ff5a5d5a" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item android:id="@android:id/secondaryProgress">
|
||||
<clip>
|
||||
<shape>
|
||||
<corners android:radius="5dip" />
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:centerColor="#80ffb600"
|
||||
android:centerY="0.75"
|
||||
android:endColor="#80ffd300"
|
||||
android:startColor="#80ffd300" />
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
<item android:id="@android:id/progress">
|
||||
<clip>
|
||||
<shape>
|
||||
<corners android:radius="5dip" />
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:endColor="#3333FF"
|
||||
android:startColor="#3333FF" />
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
|
||||
</layer-list>
|
||||
@@ -5,9 +5,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:background="@color/black">
|
||||
|
||||
<com.futo.platformplayer.views.others.CircularProgressBar
|
||||
<com.futo.platformplayer.views.TargetTapLoaderView
|
||||
android:id="@+id/test_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
app:progress="0%"
|
||||
app:strokeWidth="20dp" />
|
||||
android:layout_height="240dp" />
|
||||
</FrameLayout>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?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>
|
||||
@@ -263,8 +263,8 @@
|
||||
android:textSize="13dp"
|
||||
android:letterSpacing="0"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:layout_marginStart="20dp"
|
||||
android:backgroundTint="#2F2F2F"
|
||||
android:layout_marginStart="5dp"
|
||||
android:backgroundTint="@color/colorPrimary"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:text="@string/go_now"/>
|
||||
<Button
|
||||
@@ -277,9 +277,9 @@
|
||||
android:textSize="13dp"
|
||||
android:letterSpacing="0"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:backgroundTint="#481414"
|
||||
android:text="@string/prevent"/>
|
||||
android:layout_marginEnd="5dp"
|
||||
android:backgroundTint="#2F2F2F"
|
||||
android:text="@string/dismiss"/>
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -64,4 +64,11 @@
|
||||
app:controller_layout_id="@layout/video_player_ui_fullscreen"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<com.futo.platformplayer.views.TargetTapLoaderView
|
||||
android:id="@+id/loader_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -189,4 +189,14 @@
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<com.futo.platformplayer.views.TargetTapLoaderView
|
||||
android:id="@+id/loader_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
Submodule app/src/stable/assets/sources/apple-podcasts updated: 9aa31c5e87...089987f007
Submodule app/src/stable/assets/sources/bitchute updated: b31ced36b9...b213f91c0b
Submodule app/src/stable/assets/sources/crunchyroll updated: 1aa91f216c...534bded369
Submodule app/src/stable/assets/sources/dailymotion updated: ffd40f2006...850eb8122d
Submodule app/src/stable/assets/sources/kick updated: ffdf4cda38...b7173f1538
Submodule app/src/stable/assets/sources/nebula updated: 97a5ad5a37...090cd76dfa
Submodule app/src/stable/assets/sources/odysee updated: 215cd9bd70...736c6b953a
Submodule app/src/stable/assets/sources/patreon updated: e5dce87c9d...6880b30b71
Submodule app/src/stable/assets/sources/peertube updated: 6e7f943b0b...56bff39123
Submodule app/src/stable/assets/sources/rumble updated: 932fdf78de...401274b1ec
Submodule app/src/stable/assets/sources/soundcloud updated: f8234d6af8...048acef152
Submodule app/src/stable/assets/sources/spotify updated: 47e76a96e5...8c0f03f5fb
Submodule app/src/stable/assets/sources/twitch updated: 08346f9177...8de3ab18f5
Submodule app/src/stable/assets/sources/youtube updated: a297a0a788...2b724f21a7
@@ -0,0 +1,257 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import CSSColor
|
||||
import org.junit.Assert.assertEquals
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CSSColorTest {
|
||||
|
||||
private fun approxEq(expected: Float, actual: Float, eps: Float = 1e-5f) {
|
||||
assertTrue(abs(expected - actual) <= eps, "Expected $expected but got $actual")
|
||||
}
|
||||
|
||||
@Test fun `hex #RRGGBB parses correctly`() {
|
||||
val c = CSSColor.parseColor("#336699")
|
||||
assertEquals(0x33, c.red)
|
||||
assertEquals(0x66, c.green)
|
||||
assertEquals(0x99, c.blue)
|
||||
assertEquals(255, c.alpha)
|
||||
}
|
||||
|
||||
@Test fun `hex #RGB shorthand expands`() {
|
||||
val c = CSSColor.parseColor("#369")
|
||||
assertEquals(0x33, c.red)
|
||||
assertEquals(0x66, c.green)
|
||||
assertEquals(0x99, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `hex #RRGGBBAA parses alpha`() {
|
||||
val c = CSSColor.parseColor("#33669980")
|
||||
assertEquals(0x33, c.red)
|
||||
assertEquals(0x66, c.green)
|
||||
assertEquals(0x99, c.blue)
|
||||
approxEq(128 / 255f, c.a)
|
||||
assertEquals(128, c.alpha)
|
||||
}
|
||||
|
||||
@Test fun `hex #RGBA shorthand expands with alpha`() {
|
||||
val c = CSSColor.parseColor("#3698")
|
||||
assertEquals(0x33, c.red)
|
||||
assertEquals(0x66, c.green)
|
||||
assertEquals(0x99, c.blue)
|
||||
assertEquals(0x88, c.alpha)
|
||||
}
|
||||
|
||||
@Test fun `hex uppercase shorthand parses`() {
|
||||
val c = CSSColor.parseColor("#AbC")
|
||||
// expands to AABBCC
|
||||
assertEquals(0xAA, c.red)
|
||||
assertEquals(0xBB, c.green)
|
||||
assertEquals(0xCC, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `rgb(ints) functional parser`() {
|
||||
val c = CSSColor.parseColor("rgb(255,128,0)")
|
||||
assertEquals(255, c.red)
|
||||
assertEquals(128, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
assertEquals(255, c.alpha)
|
||||
}
|
||||
|
||||
@Test fun `rgb(percent) functional parser`() {
|
||||
val c = CSSColor.parseColor("rgb(100%,50%,0%)")
|
||||
assertEquals(255, c.red)
|
||||
assertEquals(128, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `rgba raw‐float alpha functional parser`() {
|
||||
val c = CSSColor.parseColor("rgba(255,0,0,0.5)")
|
||||
assertEquals(255, c.red)
|
||||
assertEquals(0, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
approxEq(0.5f, c.a)
|
||||
}
|
||||
|
||||
@Test fun `rgba percent alpha functional parser`() {
|
||||
val c = CSSColor.parseColor("rgba(100%,0%,0%,50%)")
|
||||
assertEquals(255, c.red)
|
||||
assertEquals(0, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
approxEq(0.5f, c.a)
|
||||
}
|
||||
|
||||
@Test fun `hsl() functional parser yields correct RGB`() {
|
||||
// pure green: hue=120°, sat=100%, light=50%
|
||||
val c = CSSColor.parseColor("hsl(120,100%,50%)")
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(255, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `hsla percent alpha functional parser`() {
|
||||
val c = CSSColor.parseColor("hsla(240,100%,50%,25%)")
|
||||
// pure blue, alpha 25%
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(0, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
approxEq(0.25f, c.a)
|
||||
}
|
||||
|
||||
@Test fun `hsla raw‐float alpha functional parser`() {
|
||||
val c = CSSColor.parseColor("hsla(240,100%,50%,0.25)")
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(0, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
approxEq(0.25f, c.a)
|
||||
}
|
||||
|
||||
@Test fun `hsl radian unit parsing`() {
|
||||
// 180° = π radians → cyan
|
||||
val c = CSSColor.parseColor("hsl(${PI}rad,100%,50%)")
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(255, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `hsl turn unit parsing`() {
|
||||
// 0.5 turn = 180° → cyan
|
||||
val c = CSSColor.parseColor("hsl(0.5turn,100%,50%)")
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(255, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `hsl grad unit parsing`() {
|
||||
// 200 grad = 180° → cyan
|
||||
val c = CSSColor.parseColor("hsl(200grad,100%,50%)")
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(255, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `named colors parse`() {
|
||||
val red = CSSColor.parseColor("red")
|
||||
assertEquals(255, red.red)
|
||||
assertEquals(0, red.green)
|
||||
assertEquals(0, red.blue)
|
||||
|
||||
val rebecca = CSSColor.parseColor("rebeccapurple")
|
||||
assertEquals(0x66, rebecca.red)
|
||||
assertEquals(0x33, rebecca.green)
|
||||
assertEquals(0x99, rebecca.blue)
|
||||
|
||||
val transparent = CSSColor.parseColor("transparent")
|
||||
assertEquals(0, transparent.alpha)
|
||||
}
|
||||
|
||||
@Test fun `round-trip Android Int ↔ CSSColor`() {
|
||||
val original = CSSColor(0.2f, 0.4f, 0.6f, 0.8f)
|
||||
val colorInt = original.toRgbaInt()
|
||||
val back = CSSColor.fromRgba(colorInt)
|
||||
approxEq(original.r, back.r)
|
||||
approxEq(original.g, back.g)
|
||||
approxEq(original.b, back.b)
|
||||
approxEq(original.a, back.a)
|
||||
}
|
||||
|
||||
@Test fun `individual channel setters`() {
|
||||
val c = CSSColor(0f,0f,0f,1f)
|
||||
c.red = 128; assertEquals(128, c.red); approxEq(128/255f, c.r)
|
||||
c.green = 64; assertEquals(64, c.green); approxEq(64/255f, c.g)
|
||||
c.blue = 32; assertEquals(32, c.blue); approxEq(32/255f, c.b)
|
||||
c.alpha = 200; assertEquals(200, c.alpha); approxEq(200/255f, c.a)
|
||||
}
|
||||
|
||||
@Test fun `hsl channel setters update RGB`() {
|
||||
val c = CSSColor.parseColor("hsl(0,100%,50%)") // red
|
||||
c.hue = 120f // → green
|
||||
assertEquals(0, c.red)
|
||||
assertEquals(255, c.green)
|
||||
assertEquals(0, c.blue)
|
||||
|
||||
c.saturation = 0f // → gray
|
||||
assertTrue(c.red == c.green && c.green == c.blue)
|
||||
}
|
||||
|
||||
@Test fun `convenience modifiers chain as expected`() {
|
||||
val c = CSSColor.parseColor("#888888")
|
||||
.lighten(0.1f)
|
||||
.saturate(0.2f)
|
||||
.rotateHue(45f)
|
||||
|
||||
approxEq(0.633f, c.lightness, eps = 1e-3f)
|
||||
approxEq(0.2f, c.saturation, eps = 1e-3f)
|
||||
approxEq(45f, c.hue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid formats throw IllegalArgumentException`() {
|
||||
listOf("", "rgb()", "hsl(0,0)", "#12", "rgba(0,0,0,150%)", "hsla(0,0%,0%,2)").forEach { bad ->
|
||||
try {
|
||||
CSSColor.parseColor(bad)
|
||||
assert(false)
|
||||
} catch (e: Throwable) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `out‐of‐range RGB ints clamp`() {
|
||||
val c = CSSColor.parseColor("rgb(300,-20, 260)")
|
||||
assertEquals(255, c.red)
|
||||
assertEquals(0, c.green)
|
||||
assertEquals(255, c.blue)
|
||||
}
|
||||
|
||||
@Test fun `parser is case- and whitespace-tolerant`() {
|
||||
val a = CSSColor.parseColor(" RgB( 10 ,20, 30 )")
|
||||
assertEquals(10, a.red)
|
||||
assertEquals(20, a.green)
|
||||
assertEquals(30, a.blue)
|
||||
|
||||
val b = CSSColor.parseColor(" ReBeCcaPURple ")
|
||||
assertEquals(0x66, b.red)
|
||||
assertEquals(0x33, b.green)
|
||||
assertEquals(0x99, b.blue)
|
||||
}
|
||||
|
||||
@Test fun `hsl lightness extremes`() {
|
||||
// lightness = 0 → black
|
||||
val black = CSSColor.parseColor("hsl(123,45%,0%)")
|
||||
assertEquals(0, black.red)
|
||||
assertEquals(0, black.green)
|
||||
assertEquals(0, black.blue)
|
||||
// lightness = 100% → white
|
||||
val white = CSSColor.parseColor("hsl(321,55%,100%)")
|
||||
assertEquals(255, white.red)
|
||||
assertEquals(255, white.green)
|
||||
assertEquals(255, white.blue)
|
||||
// saturation = 0 → gray (r==g==b)
|
||||
val gray = CSSColor.parseColor("hsl(50,0%,60%)")
|
||||
assertTrue(gray.red == gray.green && gray.green == gray.blue)
|
||||
}
|
||||
|
||||
@Test fun `hsl negative and large hues wrap`() {
|
||||
val c1 = CSSColor.parseColor("hsl(-120,100%,50%)") // → same as 240°
|
||||
assertEquals(0, c1.red)
|
||||
assertEquals(0, c1.green)
|
||||
assertEquals(255, c1.blue)
|
||||
|
||||
val c2 = CSSColor.parseColor("hsl(480,100%,50%)") // → same as 120°
|
||||
assertEquals(0, c2.red)
|
||||
assertEquals(255, c2.green)
|
||||
assertEquals(0, c2.blue)
|
||||
}
|
||||
|
||||
@Test fun `lighten then darken returns original`() {
|
||||
val base = CSSColor.parseColor("#123456")
|
||||
val round = base.lighten(0.2f).darken(0.2f)
|
||||
approxEq(base.r, round.r)
|
||||
approxEq(base.g, round.g)
|
||||
approxEq(base.b, round.b)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import java.util.Random
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
/*
|
||||
class NoiseProtocolTest {
|
||||
constructor() {
|
||||
Logger.setLogConsumers(listOf(
|
||||
@@ -625,4 +625,4 @@ class NoiseProtocolTest {
|
||||
throw Exception("Byte mismatch at index ${i}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
Submodule app/src/unstable/assets/sources/apple-podcasts updated: 9aa31c5e87...089987f007
Submodule app/src/unstable/assets/sources/bitchute updated: b31ced36b9...b213f91c0b
Submodule app/src/unstable/assets/sources/crunchyroll updated: 1aa91f216c...534bded369
Submodule app/src/unstable/assets/sources/dailymotion updated: ffd40f2006...850eb8122d
Submodule app/src/unstable/assets/sources/kick updated: ffdf4cda38...b7173f1538
Submodule app/src/unstable/assets/sources/nebula updated: 97a5ad5a37...090cd76dfa
Submodule app/src/unstable/assets/sources/odysee updated: 215cd9bd70...736c6b953a
Submodule app/src/unstable/assets/sources/patreon updated: e5dce87c9d...6880b30b71
Submodule app/src/unstable/assets/sources/peertube updated: 6e7f943b0b...56bff39123
Submodule app/src/unstable/assets/sources/rumble updated: 932fdf78de...401274b1ec
Submodule app/src/unstable/assets/sources/soundcloud updated: f8234d6af8...048acef152
Submodule app/src/unstable/assets/sources/spotify updated: 47e76a96e5...8c0f03f5fb
Submodule app/src/unstable/assets/sources/twitch updated: 08346f9177...8de3ab18f5
Submodule app/src/unstable/assets/sources/youtube updated: a297a0a788...2b724f21a7
+1
-1
Submodule dep/polycentricandroid updated: f87f00ab9e...278e3c2feb
Reference in New Issue
Block a user