mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-22 07:45:20 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 226d5c1c68 | |||
| 8b36865f5e | |||
| c3be5f6dc5 | |||
| 4c0eceaa8e |
@@ -26,7 +26,7 @@ body:
|
||||
label: Reproduction steps
|
||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||
placeholder: |
|
||||
0. Play a YouTube video
|
||||
0. Play a Youtube video
|
||||
1. Press on Download button
|
||||
2. Select quality 1440p
|
||||
3. Grayjay crashes when attempting to download
|
||||
@@ -83,7 +83,7 @@ body:
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "YouTube"
|
||||
- "Youtube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -106,9 +106,3 @@
|
||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||
path = app/src/stable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
[submodule "app/src/stable/assets/sources/mixcloud"]
|
||||
path = app/src/stable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||
path = app/src/unstable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
|
||||
+10
-12
@@ -39,7 +39,7 @@ protobuf {
|
||||
|
||||
android {
|
||||
namespace 'com.futo.platformplayer'
|
||||
compileSdk 36
|
||||
compileSdk 34
|
||||
flavorDimensions "buildType"
|
||||
productFlavors {
|
||||
stable {
|
||||
@@ -97,7 +97,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk 28
|
||||
targetSdk 35
|
||||
targetSdk 34
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
@@ -154,10 +154,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
//implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
@@ -174,6 +173,7 @@ dependencies {
|
||||
|
||||
//HTTP
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.11.0"
|
||||
|
||||
//JSON
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
|
||||
@@ -206,6 +206,8 @@ dependencies {
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.70'
|
||||
implementation "com.googlecode.plist:dd-plist:1.23"
|
||||
|
||||
//Protobuf
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
|
||||
@@ -222,7 +224,9 @@ dependencies {
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
//Payment
|
||||
implementation 'com.stripe:stripe-android:20.35.1'
|
||||
implementation('com.stripe:stripe-android:20.35.1') {
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15to18'
|
||||
}
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
@@ -231,10 +235,4 @@ dependencies {
|
||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
//Rust casting SDK
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
||||
// Polycentricandroid includes this
|
||||
exclude group: 'net.java.dev.jna'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.futo.platformplayer.casting.SRPClient
|
||||
import com.futo.platformplayer.casting.TLV8Item
|
||||
import com.futo.platformplayer.casting.TLV8Tag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import java.math.BigInteger
|
||||
import org.junit.Test
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@OptIn(ExperimentalStdlibApi::class, ExperimentalUnsignedTypes::class)
|
||||
class AirPlay2Test {
|
||||
@Test
|
||||
fun testSRP() {
|
||||
val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" +
|
||||
"020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" +
|
||||
"4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" +
|
||||
"EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" +
|
||||
"98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" +
|
||||
"9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" +
|
||||
"E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
|
||||
"3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" +
|
||||
"A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" +
|
||||
"ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" +
|
||||
"D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" +
|
||||
"08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray())
|
||||
|
||||
val g = BigInteger(1, "05".hexToByteArray())
|
||||
val I = "alice"
|
||||
val p = "password123"
|
||||
val a = BigInteger(1, "60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393".replace(" ", "").hexToByteArray())
|
||||
val A = BigInteger(1, ("FAB6F5D2 615D1E32 3512E799 1CC37443 F487DA60 4CA8C923 0FCB04E5 41DCE628" +
|
||||
"0B27CA46 80B0374F 179DC3BD C7553FE6 2459798C 701AD864 A91390A2 8C93B644" +
|
||||
"ADBF9C00 745B942B 79F9012A 21B9B787 82319D83 A1F83628 66FBD6F4 6BFC0DDB" +
|
||||
"2E1AB6E4 B45A9906 B82E37F0 5D6F97F6 A3EB6E18 2079759C 4F684783 7B62321A" +
|
||||
"C1B4FA68 641FCB4B B98DD697 A0C73641 385F4BAB 25B79358 4CC39FC8 D48D4BD8" +
|
||||
"67A9A3C1 0F8EA121 70268E34 FE3BBE6F F89998D6 0DA2F3E4 283CBEC1 393D52AF" +
|
||||
"724A5723 0C604E9F BCE583D7 613E6BFF D67596AD 121A8707 EEC46944 95703368" +
|
||||
"6A155F64 4D5C5863 B48F61BD BF19A53E AB6DAD0A 186B8C15 2E5F5D8C AD4B0EF8" +
|
||||
"AA4EA500 8834C3CD 342E5E0F 167AD045 92CD8BD2 79639398 EF9E114D FAAAB919" +
|
||||
"E14E8509 89224DDD 98576D79 385D2210 902E9F9B 1F2D86CF A47EE244 635465F7" +
|
||||
"1058421A 0184BE51 DD10CC9D 079E6F16 04E7AA9B 7CF7883C 7D4CE12B 06EBE160" +
|
||||
"81E23F27 A231D184 32D7D1BB 55C28AE2 1FFCF005 F57528D1 5A88881B B3BBB7FE").replace(" ", "").hexToByteArray())
|
||||
val b = BigInteger(1, "E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20".replace(" ", "").hexToByteArray())
|
||||
val B = ("40F57088 A482D4C7 733384FE 0D301FDD CA9080AD 7D4F6FDF 09A01006 C3CB6D56" +
|
||||
"2E41639A E8FA21DE 3B5DBA75 85B27558 9BDB2798 63C56280 7B2B9908 3CD1429C" +
|
||||
"DBE89E25 BFBD7E3C AD3173B2 E3C5A0B1 74DA6D53 91E6A06E 465F037A 40062548" +
|
||||
"39A56BF7 6DA84B1C 94E0AE20 8576156F E5C140A4 BA4FFC9E 38C3B07B 88845FC6" +
|
||||
"F7DDDA93 381FE0CA 6084C4CD 2D336E54 51C464CC B6EC65E7 D16E548A 273E8262" +
|
||||
"84AF2559 B6264274 215960FF F47BDD63 D3AFF064 D6137AF7 69661C9D 4FEE4738" +
|
||||
"2603C88E AA098058 1D077584 61B777E4 356DDA58 35198B51 FEEA308D 70F75450" +
|
||||
"B71675C0 8C7D8302 FD7539DD 1FF2A11C B4258AA7 0D234436 AA42B6A0 615F3F91" +
|
||||
"5D55CC3B 966B2716 B36E4D1A 06CE5E5D 2EA3BEE5 A1270E87 51DA45B6 0B997B0F" +
|
||||
"FDB0F996 2FEE4F03 BEE780BA 0A845B1D 92714217 83AE6601 A61EA2E3 42E4F2E8" +
|
||||
"BC935A40 9EAD19F2 21BD1B74 E2964DD1 9FC845F6 0EFC0933 8B60B6B2 56D8CAC8" +
|
||||
"89CCA306 CC370A0B 18C8B886 E95DA0AF 5235FEF4 393020D2 B7F30569 04759042").replace(" ", "").hexToByteArray()
|
||||
val s = "BEB25379 D1A8581E B5A72767 3A2441EE".replace(" ", "").hexToByteArray()
|
||||
val v = BigInteger(1, ("9B5E0617 01EA7AEB 39CF6E35 19655A85 3CF94C75 CAF2555E F1FAF759 BB79CB47" +
|
||||
"7014E04A 88D68FFC 05323891 D4C205B8 DE81C2F2 03D8FAD1 B24D2C10 9737F1BE" +
|
||||
"BBD71F91 2447C4A0 3C26B9FA D8EDB3E7 80778E30 2529ED1E E138CCFC 36D4BA31" +
|
||||
"3CC48B14 EA8C22A0 186B222E 655F2DF5 603FD75D F76B3B08 FF895006 9ADD03A7" +
|
||||
"54EE4AE8 8587CCE1 BFDE3679 4DBAE459 2B7B904F 442B041C B17AEBAD 1E3AEBE3" +
|
||||
"CBE99DE6 5F4BB1FA 00B0E7AF 06863DB5 3B02254E C66E781E 3B62A821 2C86BEB0" +
|
||||
"D50B5BA6 D0B478D8 C4E9BBCE C2176532 6FBD1405 8D2BBDE2 C33045F0 3873E539" +
|
||||
"48D78B79 4F0790E4 8C36AED6 E880F557 427B2FC0 6DB5E1E2 E1D7E661 AC482D18" +
|
||||
"E528D729 5EF74372 95FF1A72 D4027717 13F16876 DD050AE5 B7AD53CC B90855C9" +
|
||||
"39566483 58ADFD96 6422F524 98732D68 D1D7FBEF 10D78034 AB8DCB6F 0FCF885C" +
|
||||
"C2B2EA2C 3E6AC866 09EA058A 9DA8CC63 531DC915 414DF568 B09482DD AC1954DE" +
|
||||
"C7EB714F 6FF7D44C D5B86F6B D1158109 30637C01 D0F6013B C9740FA2 C633BA89").replace(" ", "").hexToByteArray())
|
||||
val u = BigInteger(1, ("03AE5F3C 3FA9EFF1 A50D7DBB 8D2F60A1 EA66EA71 2D50AE97 6EE34641 A1CD0E51" +
|
||||
"C4683DA3 83E8595D 6CB56A15 D5FBC754 3E07FBDD D316217E 01A391A1 8EF06DFF").replace(" ", "").hexToByteArray())
|
||||
val S = ("F1036FEC D017C823 9C0D5AF7 E0FCF0D4 08B009E3 6411618A 60B23AAB BFC38339" +
|
||||
"72682312 14BAACDC 94CA1C53 F442FB51 C1B027C3 18AE238E 16414D60 D1881B66" +
|
||||
"486ADE10 ED02BA33 D098F6CE 9BCF1BB0 C46CA2C4 7F2F174C 59A9C61E 2560899B" +
|
||||
"83EF6113 1E6FB30B 714F4E43 B735C9FE 6080477C 1B83E409 3E4D456B 9BCA492C" +
|
||||
"F9339D45 BC42E67C E6C02C24 3E49F5DA 42A869EC 855780E8 4207B8A1 EA6501C4" +
|
||||
"78AAC0DF D3D22614 F531A00D 826B7954 AE8B14A9 85A42931 5E6DD366 4CF47181" +
|
||||
"496A9432 9CDE8005 CAE63C2F 9CA4969B FE840019 24037C44 6559BDBB 9DB9D4DD" +
|
||||
"142FBCD7 5EEF2E16 2C843065 D99E8F05 762C4DB7 ABD9DB20 3D41AC85 A58C05BD" +
|
||||
"4E2DBF82 2A934523 D54E0653 D376CE8B 56DCB452 7DDDC1B9 94DC7509 463A7468" +
|
||||
"D7F02B1B EB168571 4CE1DD1E 71808A13 7F788847 B7C6B7BF A1364474 B3B7E894" +
|
||||
"78954F6A 8E68D45B 85A88E4E BFEC1336 8EC0891C 3BC86CF5 00978801 78D86135" +
|
||||
"E7287234 58538858 D715B7B2 47406222 C1019F53 603F0169 52D49710 0858824C").replace(" ", "").hexToByteArray()
|
||||
val K = ("5CBC219D B052138E E1148C71 CD449896 3D682549 CE91CA24 F098468F 06015BEB" +
|
||||
"6AF245C2 093F98C3 651BCA83 AB8CAB2B 580BBF02 184FEFDF 26142F73 DF95AC50").replace(" ", "").hexToByteArray()
|
||||
|
||||
|
||||
val srp = SRPClient(N, g, I, p)
|
||||
val A_computed = srp.srp_user_start_authentication(a)
|
||||
assert(A_computed == A) { "Mismatch in A value" }
|
||||
|
||||
val triple = srp.srp_user_process_challenge_internal(s, B)
|
||||
val u_computed = triple.first
|
||||
val v_computed = triple.second
|
||||
val M_computed = triple.third
|
||||
val S_computed = srp.getS()!!
|
||||
assert(u_computed == u) { "Mismatch in u" }
|
||||
assert(v_computed == v) { "Mismatch in v" }
|
||||
//assert(M_computed.contentEquals(M)) { "Mismatch in M" }
|
||||
assert(S_computed.contentEquals(S)) { "Mismatch in session key S" }
|
||||
|
||||
val K_computed = srp.getSessionKey()!!
|
||||
assert(K_computed.contentEquals(K)) { "Mismatch in derived key K" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeSimpleSmallValue() {
|
||||
val value = byteArrayOf(0x01, 0x02, 0x03, 0x04).toUByteArray()
|
||||
val item = TLV8Item(TLV8Tag.METHOD, value)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item))
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
|
||||
assertEquals(1, decoded.size)
|
||||
assertEquals(item.tag, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(value))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeExactly255BytesNoFragmentation() {
|
||||
val data255 = UByteArray(255) { it.toUByte() }
|
||||
val item255 = TLV8Item(TLV8Tag.IDENTIFIER, data255)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item255))
|
||||
// Expect: 1 byte tag + 1 byte length + 255 bytes data
|
||||
assertEquals(257, encoded.size)
|
||||
assertEquals(TLV8Tag.IDENTIFIER.value.toByte(), encoded[0])
|
||||
assertEquals(0xFF, encoded[1].toInt() and 0xFF)
|
||||
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
assertEquals(1, decoded.size)
|
||||
assertEquals(TLV8Tag.IDENTIFIER, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(data255))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecode256BytesWithFragmentation() {
|
||||
val data256 = UByteArray(256) { it.toUByte() }
|
||||
val item256 = TLV8Item(TLV8Tag.SALT, data256)
|
||||
|
||||
val encoded = TLV8Item.encode(listOf(item256))
|
||||
// First fragment header: SALT tag + 0xFF length
|
||||
assertEquals(TLV8Tag.SALT.value.toByte(), encoded[0])
|
||||
assertEquals(0xFF, encoded[1].toInt() and 0xFF)
|
||||
|
||||
// Locate last‐fragment header: two bytes before the final data byte
|
||||
val lastFragmentIndex = encoded.size - (1 /*remaining*/ + 2)
|
||||
assertEquals(TLV8Tag.FRAGMENT_LAST.value.toByte(), encoded[lastFragmentIndex])
|
||||
assertEquals(1.toByte(), encoded[lastFragmentIndex + 1])
|
||||
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
assertEquals(1, decoded.size)
|
||||
assertTrue(decoded[0].value.contentEquals(data256))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncodeAndDecodeMultipleItems() {
|
||||
val v1 = byteArrayOf(0x0A, 0x0B).toUByteArray()
|
||||
val v2 = byteArrayOf(0xFF.toByte(), 0xEE.toByte(), 0xDD.toByte()).toUByteArray()
|
||||
val items = listOf(
|
||||
TLV8Item(TLV8Tag.PROOF, v1),
|
||||
TLV8Item(TLV8Tag.ERROR, v2)
|
||||
)
|
||||
|
||||
val encoded = TLV8Item.encode(items)
|
||||
val decoded = TLV8Item.decode(encoded.toUByteArray())
|
||||
|
||||
assertEquals(2, decoded.size)
|
||||
assertEquals(TLV8Tag.PROOF, decoded[0].tag)
|
||||
assertTrue(decoded[0].value.contentEquals(v1))
|
||||
assertEquals(TLV8Tag.ERROR, decoded[1].tag)
|
||||
assertTrue(decoded[1].value.contentEquals(v2))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeUnknownTagThrowsIllegalArgumentException() {
|
||||
// Tag 0x10 isn’t defined in TLV8Tag
|
||||
val bogus = byteArrayOf(0x10, 0x00).toUByteArray()
|
||||
TLV8Item.decode(bogus)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeTruncatedLengthByteThrowsIllegalArgumentException() {
|
||||
// Only a tag byte, missing length byte
|
||||
val onlyTag = byteArrayOf(TLV8Tag.STATE.value.toByte()).toUByteArray()
|
||||
TLV8Item.decode(onlyTag)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecodeTruncatedDataThrowsIllegalArgumentException() {
|
||||
// Declared length = 2, but only 1 data byte follows
|
||||
val arr = buildList {
|
||||
add(TLV8Tag.FLAGS.value.toByte())
|
||||
add(2) // length
|
||||
add(0x5A) // only one byte of data
|
||||
}.toByteArray().toUByteArray()
|
||||
TLV8Item.decode(arr)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -153,30 +153,30 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".activities.TestActivity"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.DeveloperActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ExceptionActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.CaptchaActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.AddSourceActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -189,54 +189,54 @@
|
||||
<activity
|
||||
android:name=".activities.AddSourceOptionsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricHomeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricBackupActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricCreateProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricWhyActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricImportProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ManageTabsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.QRCaptureActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.FCastGuideActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncHomeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncPairActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncShowPairingCodeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1022,35 +1022,15 @@
|
||||
return x.value
|
||||
});
|
||||
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = settingsToUse;
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
else
|
||||
parameterVals.push(settingsToUse);
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
|
||||
@@ -67,7 +67,6 @@ class ScriptException extends Error {
|
||||
super(arguments[0]);
|
||||
this.plugin_type = "ScriptException";
|
||||
this.message = arguments[0];
|
||||
this.msg = arguments[0];
|
||||
}
|
||||
else {
|
||||
super(msg);
|
||||
@@ -252,9 +251,6 @@ class PlatformVideo extends PlatformContent {
|
||||
this.duration = obj.duration ?? -1; //Long
|
||||
this.viewCount = obj.viewCount ?? -1; //Long
|
||||
|
||||
this.playbackTime = obj.playbackTime ?? -1;
|
||||
this.playbackDate = obj.playbackDate ?? undefined;
|
||||
|
||||
this.isLive = obj.isLive ?? false; //Boolean
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
}
|
||||
@@ -468,20 +464,14 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
||||
this.getLicenseRequestExecutor = () => {
|
||||
return {
|
||||
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||
const response = http.POST(
|
||||
return http.POST(
|
||||
url,
|
||||
license_request_data,
|
||||
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
if (!response.body) {
|
||||
throw new ScriptException("Unable to acquire license key");
|
||||
}
|
||||
|
||||
return response.body;
|
||||
}
|
||||
).body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,12 +707,11 @@ class LiveEventViewCount extends LiveEvent {
|
||||
}
|
||||
}
|
||||
class LiveEventRaid extends LiveEvent {
|
||||
constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
|
||||
constructor(targetUrl, targetName, targetThumbnail) {
|
||||
super(100);
|
||||
this.targetUrl = targetUrl;
|
||||
this.targetName = targetName;
|
||||
this.targetThumbnail = targetThumbnail;
|
||||
this.isOutgoing = isOutgoing ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,7 +784,6 @@ let plugin = {
|
||||
//To override by plugin
|
||||
const source = {
|
||||
getHome() { return new ContentPager([], false, {}); },
|
||||
getShorts() { return new VideoPager([], false, {}); },
|
||||
|
||||
enable(config){ },
|
||||
disable() {},
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
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()
|
||||
@@ -216,9 +216,10 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
||||
return InetAddress.getByAddress(this);
|
||||
}
|
||||
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 10000
|
||||
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||
if(addresses.isEmpty())
|
||||
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||
@@ -231,7 +232,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
|
||||
val socket = Socket()
|
||||
|
||||
try {
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
||||
} catch (e: Throwable) {
|
||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||
socket.close()
|
||||
@@ -262,7 +263,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
socket.connect(InetSocketAddress(address, port), timeoutMs);
|
||||
socket.connect(InetSocketAddress(address, port), timeout);
|
||||
|
||||
synchronized(syncObject) {
|
||||
if (connectedSocket == null) {
|
||||
|
||||
@@ -69,4 +69,16 @@ fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a leading zero byte if BigInteger.toByteArray() included it just to indicate a positive sign.
|
||||
* Mirrors C's expectation that BN_bn2bin yields exactly the “minimal” big‐endian representation.
|
||||
*/
|
||||
fun ByteArray.stripLeadingZero(): ByteArray {
|
||||
return if (this.size > 1 && this[0] == 0.toByte()) {
|
||||
this.copyOfRange(1, this.size)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,12 @@ 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.api.media.platforms.js.JSClient
|
||||
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.CancellationException
|
||||
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.CountDownLatch
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType
|
||||
|
||||
|
||||
//V8
|
||||
@@ -193,198 +174,4 @@ 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 {
|
||||
if(p0 is V8ValueObject)
|
||||
p0.setWeak();
|
||||
promiseResult = p0 as T;
|
||||
}
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
plugin.registerPromise(this) {
|
||||
promiseException = CancellationException("Cancelled by system");
|
||||
latch.countDown();
|
||||
}
|
||||
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
||||
|
||||
|
||||
if(!promise.isPending) {
|
||||
try {
|
||||
Logger.i("V8", "V8Promise resolved synchronously");
|
||||
if(promise.isFulfilled)
|
||||
promiseResult = promise.getResult<T>();
|
||||
else
|
||||
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
promiseException = ex;
|
||||
}
|
||||
}
|
||||
else {
|
||||
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?) {
|
||||
try {
|
||||
plugin.resolvePromise(promise);
|
||||
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
|
||||
Logger.i("V8", "Promise rejected, setting exception");
|
||||
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("V8", "Rejection handling failed?" , ex);
|
||||
}
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
try {
|
||||
plugin.resolvePromise(promise);
|
||||
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
|
||||
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("V8", "Catching handling failed?" , ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
plugin.registerPromise(promise) {
|
||||
if(def.isActive)
|
||||
def.cancel("Cancelled by system");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||
val p0 = this;
|
||||
if(p0 is V8ValueObject) {
|
||||
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
|
||||
/*
|
||||
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
||||
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||
return Throwable("Promise Failed: " + pluginType + msg);
|
||||
*/
|
||||
}
|
||||
else if(p0 is V8ValueString)
|
||||
return Throwable("Promise Failed:" + p0.value);
|
||||
else
|
||||
return NotImplementedError("onCatch promise not implemented..");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
|
||||
try {
|
||||
return this.await();
|
||||
}
|
||||
catch(ex: CancellationException) {
|
||||
if(ex.cause != null) {
|
||||
throw ex.cause!!;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsCompat.Type
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.core.view.updatePadding
|
||||
import kotlin.math.max
|
||||
|
||||
class RootInsetsController private constructor(
|
||||
private val activity: Activity,
|
||||
private val window: Window,
|
||||
private val root: ViewGroup
|
||||
) {
|
||||
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
|
||||
|
||||
private val basePaddingLeft = root.paddingLeft
|
||||
private val basePaddingTop = root.paddingTop
|
||||
private val basePaddingRight = root.paddingRight
|
||||
private val basePaddingBottom = root.paddingBottom
|
||||
|
||||
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
|
||||
private var fullscreen = false
|
||||
|
||||
init {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||
currentInsets = insets
|
||||
applyPadding()
|
||||
insets
|
||||
}
|
||||
|
||||
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
|
||||
}
|
||||
|
||||
private fun effectiveInsets(): Insets {
|
||||
if (fullscreen) return Insets.NONE
|
||||
|
||||
val sys = currentInsets.getInsets(Type.systemBars())
|
||||
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
|
||||
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
|
||||
|
||||
val top = if (portrait) max(sys.top, cut.top) else sys.top
|
||||
return Insets.of(sys.left, top, sys.right, sys.bottom)
|
||||
}
|
||||
|
||||
|
||||
private fun applyPadding() {
|
||||
val e = effectiveInsets()
|
||||
root.updatePadding(
|
||||
left = basePaddingLeft + e.left,
|
||||
top = basePaddingTop + e.top,
|
||||
right = basePaddingRight + e.right,
|
||||
bottom = basePaddingBottom + e.bottom
|
||||
)
|
||||
}
|
||||
|
||||
private fun forceRelayoutAndInsets() {
|
||||
root.post {
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
applyPadding()
|
||||
root.post {
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
applyPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
|
||||
fullscreen = true
|
||||
if (allowCutoutShortEdges) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
controller.hide(Type.systemBars())
|
||||
forceRelayoutAndInsets()
|
||||
}
|
||||
|
||||
fun exitFullscreen() {
|
||||
fullscreen = false
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||
}
|
||||
controller.show(Type.systemBars())
|
||||
forceRelayoutAndInsets()
|
||||
}
|
||||
|
||||
fun onConfigurationChanged() {
|
||||
forceRelayoutAndInsets()
|
||||
}
|
||||
|
||||
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
|
||||
controller.isAppearanceLightStatusBars = lightStatus
|
||||
controller.isAppearanceLightNavigationBars = lightNav
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
|
||||
return RootInsetsController(activity, activity.window, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
@@ -35,7 +34,6 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -203,8 +201,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
8 -> "zh";
|
||||
9 -> "ru";
|
||||
10 -> "ar";
|
||||
11 -> "it";
|
||||
12 -> "tr";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -607,16 +603,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 2.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||
var shortsPregenerate: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
|
||||
@FormFieldWarning(R.string.shorts_fit_video_warning)
|
||||
var shortsFitVideo: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -719,11 +705,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowLinkLocalIpv4: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = false
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
@@ -1106,39 +1087,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||
var localConnections: Boolean = true;
|
||||
|
||||
|
||||
|
||||
var syncServerUrl: String? = null;
|
||||
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
|
||||
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||
fun configureSyncServer() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||
"Enter the url to your relay server",
|
||||
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||
null,
|
||||
syncServerUrl ?: "",
|
||||
"YourRelayServerDomain.com", 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Reset", {
|
||||
syncServerUrl = null;
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action.withInput("Configure", {
|
||||
syncServerUrl = it?.text
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.futo.platformplayer.dialogs.ConnectedCastingDialog
|
||||
import com.futo.platformplayer.dialogs.ImportDialog
|
||||
import com.futo.platformplayer.dialogs.ImportOptionsDialog
|
||||
import com.futo.platformplayer.dialogs.MigrateDialog
|
||||
import com.futo.platformplayer.dialogs.PairingCodeDialog
|
||||
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.ProgressDialog
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
@@ -113,8 +114,8 @@ class UIDialogs {
|
||||
currentDialog.code,
|
||||
currentDialog.defaultCloseAction,
|
||||
*currentDialog.actions.map {
|
||||
return@map Action.withInput(it.text, { str ->
|
||||
it.invokeAction(str);
|
||||
return@map Action(it.text, {
|
||||
it.action();
|
||||
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
||||
}, it.style);
|
||||
}.toTypedArray());
|
||||
@@ -203,9 +204,7 @@ class UIDialogs {
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||
}
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
|
||||
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
builder.setView(view);
|
||||
@@ -228,16 +227,6 @@ class UIDialogs {
|
||||
this.text = textDetails;
|
||||
}
|
||||
};
|
||||
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
|
||||
inputView.apply {
|
||||
if (input == null && placeholder == null) this.visibility = View.GONE;
|
||||
else {
|
||||
this.text = input ?: "";
|
||||
this.hint = placeholder ?: "";
|
||||
this.visibility = View.VISIBLE;
|
||||
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||
}
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if (code == null) this.visibility = View.GONE;
|
||||
else {
|
||||
@@ -262,7 +251,7 @@ class UIDialogs {
|
||||
buttonView.textSize = 14f;
|
||||
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
||||
buttonView.text = act.text;
|
||||
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
|
||||
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
|
||||
when(act.style) {
|
||||
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
||||
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
||||
@@ -287,7 +276,7 @@ class UIDialogs {
|
||||
};
|
||||
dialog.setOnCancelListener {
|
||||
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
||||
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
|
||||
actions[defaultCloseAction].action();
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
registerDialogClosed(dialog);
|
||||
@@ -436,7 +425,7 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
fun showCastingDialog(context: Context) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null) {
|
||||
val dialog = ConnectedCastingDialog(context);
|
||||
@@ -444,7 +433,6 @@ class UIDialogs {
|
||||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
@@ -457,28 +445,33 @@ class UIDialogs {
|
||||
if (c is Activity) {
|
||||
dialog.setOwnerActivity(c);
|
||||
}
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
fun showCastingTutorialDialog(context: Context) {
|
||||
val dialog = CastingHelpDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
fun showCastingAddDialog(context: Context) {
|
||||
val dialog = CastingAddDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
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();
|
||||
}
|
||||
@@ -547,36 +540,17 @@ class UIDialogs {
|
||||
}
|
||||
class Action {
|
||||
val text: String;
|
||||
val action: ((DialogResult?)->Unit);
|
||||
val action: ()->Unit;
|
||||
val style: ActionStyle;
|
||||
var center: Boolean;
|
||||
|
||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = { action() };
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = action;
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
|
||||
fun invokeAction(input: DialogResult? = null) {
|
||||
this.action(input);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
|
||||
return Action(text, action, style, center);
|
||||
}
|
||||
}
|
||||
}
|
||||
class DialogResult(
|
||||
val text: String?
|
||||
);
|
||||
enum class ActionStyle {
|
||||
NONE,
|
||||
PRIMARY,
|
||||
|
||||
@@ -129,163 +129,115 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(
|
||||
container.context,
|
||||
container,
|
||||
"Subscription Settings",
|
||||
null,
|
||||
true,
|
||||
listOf()
|
||||
);
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(
|
||||
listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications =
|
||||
menu?.selectOption(null, "notifications", true, true)
|
||||
?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()
|
||||
) else null,
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups =
|
||||
ArrayList<SubscriptionGroup>(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
|
||||
null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if (it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
it.id
|
||||
)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if (it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
actualGroup
|
||||
);
|
||||
groups.addAll(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()
|
||||
),
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive =
|
||||
menu?.selectOption(null, "fetchLive", true, true)
|
||||
?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams =
|
||||
menu?.selectOption(null, "fetchStreams", true, true)
|
||||
?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts =
|
||||
menu?.selectOption(null, "fetchPosts", true, true)
|
||||
?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
@@ -293,82 +245,61 @@ class UISlideOverlays {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||
showCreateSubscriptionGroup(container, subscription.channel);
|
||||
}, false)*/
|
||||
).filterNotNull()
|
||||
);
|
||||
).filterNotNull());
|
||||
|
||||
menu.setItems(items);
|
||||
menu.setItems(items);
|
||||
|
||||
if (subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if (subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if (subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if (subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if (subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
|
||||
if (subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Enable 'Background Update' in settings for notifications to work"
|
||||
);
|
||||
if(subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
|
||||
if (mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(
|
||||
mainContext,
|
||||
R.drawable.ic_settings,
|
||||
"Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications",
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(
|
||||
mainContext,
|
||||
SettingsActivity::class.java
|
||||
);
|
||||
intent.putExtra(
|
||||
"query",
|
||||
mainContext.getString(R.string.background_update)
|
||||
);
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
return@subscribe;
|
||||
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Android notifications are disabled"
|
||||
);
|
||||
if (mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
if(mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
return@subscribe;
|
||||
}
|
||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
||||
if(mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show subscription overlay.", e)
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -75,26 +74,9 @@ class LoginActivity : AppCompatActivity() {
|
||||
finish();
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
@@ -104,35 +86,6 @@ class LoginActivity : AppCompatActivity() {
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
|
||||
/*
|
||||
var specifiedScale = false;
|
||||
var specifiedDesktop = false;
|
||||
if(uiMods.size > 0 && url != null) {
|
||||
synchronized(uiMods) {
|
||||
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||
if(uimod != null) {
|
||||
if(uimod.scale != null) {
|
||||
currentScale =(uimod.scale * 100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
specifiedScale = true;
|
||||
}
|
||||
if(uimod.desktop != null && uimod.desktop) {
|
||||
_webView.settings.useWideViewPort = true;
|
||||
specifiedDesktop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!specifiedScale && currentScale != 100) {
|
||||
currentScale = (100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
}
|
||||
if(!specifiedDesktop && currentDesktop) {
|
||||
_webView.settings.useWideViewPort = false;
|
||||
currentDesktop = false;
|
||||
}
|
||||
*/
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -31,15 +32,14 @@ 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
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
@@ -63,7 +63,6 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
@@ -115,6 +114,7 @@ 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
|
||||
|
||||
@@ -171,7 +171,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
lateinit var _fragHistory: HistoryFragment;
|
||||
lateinit var _fragShorts: ShortsFragment;
|
||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||
lateinit var _fragDownloads: DownloadsFragment;
|
||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||
@@ -199,7 +198,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
private lateinit var _rootInsetsController: RootInsetsController
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
@@ -285,6 +283,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
@@ -299,9 +300,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
FragmentedStorage.get<Settings>();
|
||||
|
||||
rootView = findViewById(R.id.rootView);
|
||||
_rootInsetsController = RootInsetsController.attach(this, rootView)
|
||||
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
|
||||
|
||||
_fragContainerTopBar = findViewById(R.id.fragment_top_bar);
|
||||
_fragContainerMain = findViewById(R.id.fragment_main);
|
||||
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
|
||||
@@ -342,7 +340,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragShorts = ShortsFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
_fragDownloads = DownloadsFragment();
|
||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||
@@ -412,11 +409,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
if (it) {
|
||||
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
} else {
|
||||
_rootInsetsController.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
@@ -618,8 +610,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}
|
||||
|
||||
//startActivity(Intent(this, TestActivity::class.java))
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -645,11 +635,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
_rootInsetsController.onConfigurationChanged()
|
||||
}
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||
@@ -780,7 +765,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (targetData != null) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData, intent)
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
@@ -791,9 +776,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
|
||||
suspend fun handleUrlAll(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
val intent = openIntent ?: this.intent;
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
if (url.startsWith("grayjay://license/")) {
|
||||
@@ -820,11 +804,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
if (!handleContent(url, intent?.type)) {
|
||||
if (!handleContent(url, intent.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -945,12 +929,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
|
||||
val mediaItem = LocalVideoDetails.fromContent(file, mime);
|
||||
navigateWhenReady(_fragVideoDetail, mediaItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1065,7 +1043,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "handleFCast");
|
||||
|
||||
try {
|
||||
StateCasting.instance.handleUrl(url)
|
||||
StateCasting.instance.handleUrl(this, url)
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
||||
@@ -1275,7 +1253,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
ShortsFragment::class -> _fragShorts as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
DownloadsFragment::class -> _fragDownloads as T;
|
||||
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
||||
|
||||
@@ -110,7 +110,7 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
|
||||
@@ -2,24 +2,12 @@ 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 {
|
||||
|
||||
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
|
||||
@@ -37,11 +36,6 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getHome(): IPager<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets the shorts feed
|
||||
*/
|
||||
fun getShorts(): IPager<IPlatformVideo>
|
||||
|
||||
//Search
|
||||
/**
|
||||
* Gets search suggestion for the provided query string
|
||||
@@ -182,10 +176,6 @@ interface IPlatformClient {
|
||||
* Retrieves the subscriptions of the currently logged in user
|
||||
*/
|
||||
fun getUserSubscriptions(): Array<String>;
|
||||
/**
|
||||
* Retrieves the history of the currently logged in user
|
||||
*/
|
||||
fun getUserHistory(): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -27,17 +26,12 @@ class LiveChatManager {
|
||||
private val _emojiCache: EmojiCache = EmojiCache();
|
||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||
|
||||
private var _position: Long = 0;
|
||||
private var _eventsPosition: Long = 0;
|
||||
|
||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||
|
||||
private var _startCounter = 0;
|
||||
|
||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||
|
||||
val isVOD get() = _pager is JSVODEventPager;
|
||||
|
||||
var viewCount: Long = 0
|
||||
private set;
|
||||
|
||||
@@ -45,24 +39,8 @@ class LiveChatManager {
|
||||
_scope = scope;
|
||||
_pager = pager;
|
||||
viewCount = initialViewCount;
|
||||
if(pager is JSVODEventPager)
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
else
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
|
||||
if(pager is JSVODEventPager) {
|
||||
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||
//TODO: Remove this once dripfeed is done properly
|
||||
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = _eventsPosition + 1500;
|
||||
}
|
||||
else
|
||||
handleEvents(pager.getResults());
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
handleEvents(pager.getResults());
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -74,10 +52,6 @@ class LiveChatManager {
|
||||
_startCounter++;
|
||||
}
|
||||
|
||||
fun setVideoPosition(ms: Long) {
|
||||
_position = ms;
|
||||
}
|
||||
|
||||
fun getHistory(): List<IPlatformLiveEvent> {
|
||||
synchronized(_history) {
|
||||
return _history.toList();
|
||||
@@ -111,34 +85,13 @@ class LiveChatManager {
|
||||
try {
|
||||
while(_startCounter == counter) {
|
||||
var nextInterval = 1000L;
|
||||
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||
delay(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if(_pager == null || !_pager.hasMorePages())
|
||||
return@launch;
|
||||
val newEvents = if(_pager is JSVODEventPager) {
|
||||
val requestPosition = _position;
|
||||
_pager.nextPage(requestPosition.toInt());
|
||||
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
replayResults;
|
||||
}
|
||||
else {
|
||||
_pager.nextPage();
|
||||
_pager.getResults();
|
||||
}
|
||||
_pager.nextPage();
|
||||
val newEvents = _pager.getResults();
|
||||
if(_pager is JSLiveEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
else if(_pager is JSVODEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
|
||||
if(newEvents.size > 0)
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
|
||||
@@ -20,8 +20,7 @@ data class PlatformClientCapabilities(
|
||||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false,
|
||||
val hasGetChannelPlaylists: Boolean = false,
|
||||
val hasGetContentRecommendations: Boolean = false,
|
||||
val hasGetUserHistory: Boolean = false
|
||||
val hasGetContentRecommendations: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -34,10 +34,8 @@ class PlatformClientPool {
|
||||
isDead = true;
|
||||
onDead.emit(parentClient, this);
|
||||
|
||||
synchronized(_pool) {
|
||||
for (clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
}
|
||||
for(clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
val type : LiveEventType;
|
||||
var time: Long;
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -18,15 +18,12 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
val colorName: String?;
|
||||
val badges: List<String>;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
this.thumbnail = thumbnail;
|
||||
this.colorName = colorName;
|
||||
this.badges = badges ?: listOf();
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -42,8 +39,7 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
obj.getOrThrow(config, "name", contextName),
|
||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||
obj.getOrThrow(config, "message", contextName),
|
||||
colorName, badges,
|
||||
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||
colorName, badges);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
|
||||
var expire: Int = 6000;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||
this.name = name;
|
||||
|
||||
@@ -10,8 +10,6 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
||||
|
||||
val emojis: HashMap<String, String>;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(emojis: HashMap<String, String>) {
|
||||
this.emojis = emojis;
|
||||
}
|
||||
@@ -20,7 +18,8 @@ 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,7 +3,6 @@ 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 {
|
||||
@@ -12,15 +11,11 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
val targetName: String;
|
||||
val targetThumbnail: String;
|
||||
val targetUrl: String;
|
||||
val isOutgoing: Boolean;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||
constructor(name: String, url: String, thumbnail: String) {
|
||||
this.targetName = name;
|
||||
this.targetUrl = url;
|
||||
this.targetThumbnail = thumbnail;
|
||||
this.isOutgoing = isOutgoing;
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -30,8 +25,7 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
return LiveEventRaid(
|
||||
obj.getOrThrow(config, "targetName", contextName),
|
||||
obj.getOrThrow(config, "targetUrl", contextName),
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName),
|
||||
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
||||
|
||||
val viewCount: Int;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(viewCount: Int) {
|
||||
this.viewCount = viewCount;
|
||||
}
|
||||
|
||||
+3
-17
@@ -2,24 +2,10 @@ package com.futo.platformplayer.api.media.models.streams
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
|
||||
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
override val audioSources: Array<IAudioSource>;
|
||||
|
||||
constructor(video: VideoLocal) {
|
||||
videoSources = video.videoSource.toTypedArray();
|
||||
audioSources = video.audioSource.toTypedArray();
|
||||
}
|
||||
constructor(audio: LocalAudioContentSource) {
|
||||
videoSources = arrayOf()
|
||||
audioSources = arrayOf(audio);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
|
||||
this.videoSources = videoSources;
|
||||
this.audioSources = audioSources;
|
||||
}
|
||||
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
||||
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
|
||||
}
|
||||
+1
-2
@@ -14,8 +14,7 @@ class AudioUrlSource(
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
override var original: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@ open class VideoUrlSource(
|
||||
override val codec : String = "",
|
||||
override val bitrate : Int? = 0,
|
||||
|
||||
override var priority: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
override var priority: Boolean = false
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* A search result representing a video (overview data)
|
||||
@@ -13,9 +12,6 @@ interface IPlatformVideo : IPlatformContent {
|
||||
val duration: Long;
|
||||
val viewCount: Long;
|
||||
|
||||
val playbackTime: Long;
|
||||
val playbackDate: OffsetDateTime?;
|
||||
|
||||
val isLive : Boolean;
|
||||
|
||||
val isShort: Boolean;
|
||||
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class LocalVideoDetails(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val url: String,
|
||||
override val duration: Long,
|
||||
|
||||
val mimeType: String? = null,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
override val hls: IHLSManifestSource? get() = null;
|
||||
override val live: IVideoSource? get() = null;
|
||||
|
||||
|
||||
override val shareUrl: String = ""
|
||||
override val viewCount: Long = -1
|
||||
override val rating: IRating = RatingLikes(0)
|
||||
override val description: String = "";
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||
override val isShort: Boolean = false
|
||||
|
||||
fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
fun fromJson(str : String) : SerializedPlatformVideoDetails {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
companion object {
|
||||
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
if(filePath.startsWith("content://"))
|
||||
return fromContent(filePath, mimeType);
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
|
||||
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
|
||||
}
|
||||
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getFileNameFromContentUrl(url: String): String? {
|
||||
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
|
||||
cursor?.moveToFirst();
|
||||
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||
cursor?.close();
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -3,10 +3,11 @@ package com.futo.platformplayer.api.media.models.video
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
@@ -17,7 +18,7 @@ open class SerializedPlatformVideo(
|
||||
override val contentType: ContentType = ContentType.MEDIA,
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails = Thumbnails(),
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
@@ -32,10 +33,6 @@ open class SerializedPlatformVideo(
|
||||
|
||||
override val isLive: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
|
||||
+1
-4
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
@@ -42,10 +43,6 @@ open class SerializedPlatformVideoDetails(
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
|
||||
@@ -23,7 +23,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
@@ -44,7 +43,6 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -126,7 +124,6 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
@@ -272,8 +269,7 @@ open class JSClient : IPlatformClient {
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -332,13 +328,6 @@ open class JSClient : IPlatformClient {
|
||||
plugin.executeTyped("source.getHome()"));
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
|
||||
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
|
||||
ensureEnabled()
|
||||
return@isBusyWith JSVideoPager(config, this,
|
||||
plugin.executeTyped("source.getShorts()"))
|
||||
}
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
@@ -643,6 +632,7 @@ 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") {
|
||||
@@ -713,13 +703,6 @@ open class JSClient : IPlatformClient {
|
||||
.toTypedArray();
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||
ensureEnabled();
|
||||
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
try {
|
||||
plugin.start();
|
||||
|
||||
+3
-46
@@ -1,10 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
@kotlinx.serialization.Serializable
|
||||
class SourcePluginAuthConfig(
|
||||
val loginUrl: String,
|
||||
val completionUrl: String? = null,
|
||||
@@ -15,44 +11,5 @@ class SourcePluginAuthConfig(
|
||||
val userAgent: String? = null,
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null,
|
||||
val loginWarnings: List<Warning>? = null,
|
||||
val uiMods: List<UIMod>? = null
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
class Warning(
|
||||
val url: String,
|
||||
val text: String?,
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class UIMod(
|
||||
val url: String,
|
||||
val scale: Float?,
|
||||
val desktop: Boolean?
|
||||
) {
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val loginWarning: String? = null
|
||||
) { }
|
||||
@@ -48,7 +48,6 @@ class SourcePluginConfig(
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var enableInShorts: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
|
||||
+2
-20
@@ -5,16 +5,10 @@ import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -109,22 +103,12 @@ class SourcePluginDescriptor {
|
||||
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
||||
var enableHome: Boolean? = null;
|
||||
|
||||
|
||||
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
||||
var enableSearch: Boolean? = null;
|
||||
|
||||
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
|
||||
var enableShorts: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
|
||||
var sync = Sync();
|
||||
@Serializable
|
||||
class Sync {
|
||||
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
|
||||
var enableHistorySync: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
@@ -159,8 +143,6 @@ class SourcePluginDescriptor {
|
||||
tabEnabled.enableHome = config.enableInHome
|
||||
if(tabEnabled.enableSearch == null)
|
||||
tabEnabled.enableSearch = config.enableInSearch
|
||||
if(tabEnabled.enableShorts == null)
|
||||
tabEnabled.enableShorts = config.enableInShorts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -21,7 +21,6 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
@@ -86,12 +85,12 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
|
||||
}
|
||||
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
@@ -61,7 +60,7 @@ class JSComment : IPlatformComment {
|
||||
if(!_hasGetReplies)
|
||||
return null;
|
||||
|
||||
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
||||
return JSCommentPager(_config!!, plugin, obj);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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> {
|
||||
@@ -19,8 +18,8 @@ abstract class JSPager<T> : IPager<T> {
|
||||
protected var pager: V8ValueObject;
|
||||
|
||||
private var _lastResults: List<T>? = null;
|
||||
protected var _resultChanged: Boolean = true;
|
||||
protected var _hasMorePages: Boolean = false;
|
||||
private var _resultChanged: Boolean = true;
|
||||
private var _hasMorePages: Boolean = false;
|
||||
//private var _morePagesWasFalse: Boolean = false;
|
||||
|
||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
@@ -41,7 +40,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _hasMorePages && !pager.isClosed;
|
||||
return _hasMorePages;
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
@@ -50,7 +49,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invokeV8("nextPage", arrayOf<Any>());
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
|
||||
+3
-4
@@ -6,7 +6,6 @@ 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
|
||||
@@ -58,7 +57,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
_client.busy {
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeV8Void("onInit", seconds);
|
||||
_obj.invokeVoid("onInit", seconds);
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
@@ -74,7 +73,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
else {
|
||||
_client.busy {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeV8Void("onProgress", Math.floor(seconds), isPlaying);
|
||||
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
}
|
||||
@@ -87,7 +86,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
synchronized(_obj) {
|
||||
Logger.i("JSPlaybackTracker", "onConcluded");
|
||||
_client.busy {
|
||||
_obj.invokeV8Void("onConcluded", -1);
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
@@ -69,12 +68,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -14,8 +14,6 @@ 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
|
||||
@@ -57,7 +55,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
@@ -65,7 +63,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
|
||||
try {
|
||||
@@ -112,7 +110,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("cleanup", null);
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
@@ -120,7 +118,7 @@ class JSRequestExecutor {
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("cleanup", null);
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -11,8 +11,6 @@ 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;
|
||||
@@ -42,7 +40,7 @@ class JSRequestModifier: IRequestModifier {
|
||||
|
||||
return _plugin.busy {
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invokeV8("modifyRequest", url, headers);
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -39,7 +38,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
throw IllegalStateException("This subtitle doesn't support getSubtitles..");
|
||||
|
||||
return _obj.getSourcePlugin()?.busy {
|
||||
val v8String = _obj.invokeV8<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return@busy v8String.value;
|
||||
} ?: "";
|
||||
}
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
override var nextRequest: Int;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
warnIfMainThread("VODEventPager.nextPage");
|
||||
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
|
||||
pager.invokeV8<V8Value>("nextPage", ms);
|
||||
};
|
||||
if(newPager is V8ValueObject)
|
||||
pager = newPager;
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() = nextPage(0);
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
|
||||
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
@@ -21,10 +17,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val duration: Long;
|
||||
final override val viewCount: Long;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
final override val isLive: Boolean;
|
||||
final override val isShort: Boolean;
|
||||
|
||||
@@ -37,11 +29,5 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
||||
isLive = _content.getOrThrow(config, "isLive", contextName);
|
||||
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
||||
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
|
||||
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
|
||||
if(playbackDateInt == null || playbackDateInt == 0.toLong())
|
||||
playbackDate = null;
|
||||
else
|
||||
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
+4
-20
@@ -7,7 +7,6 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
@@ -25,17 +24,13 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _plugin: JSClient;
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
private val _hasGetVODEvents: Boolean;
|
||||
|
||||
//Details
|
||||
override val description : String;
|
||||
@@ -51,6 +46,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
override val subtitles: List<ISubtitleSource>;
|
||||
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
_plugin = plugin;
|
||||
@@ -75,7 +71,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
_hasGetVODEvents = _content.has("getVODEvents");
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
@@ -91,7 +86,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private fun getPlaybackTrackerJS(): IPlaybackTracker? {
|
||||
return _plugin.busy {
|
||||
V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invokeV8<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_plugin, tracker);
|
||||
@@ -116,7 +111,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
return _plugin.busy {
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
}
|
||||
@@ -135,22 +130,11 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
|
||||
return _plugin.busy {
|
||||
val commentPager = _content.invokeV8<V8Value>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return@busy null;
|
||||
|
||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
|
||||
fun hasVODEvents(): Boolean{
|
||||
return _hasGetVODEvents;
|
||||
}
|
||||
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
|
||||
if(!_hasGetVODEvents)
|
||||
return@busy null;
|
||||
|
||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -6,8 +6,6 @@ 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
|
||||
@@ -27,7 +25,7 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
+2
-60
@@ -1,8 +1,6 @@
|
||||
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
|
||||
@@ -15,14 +13,8 @@ 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.logging.Logger
|
||||
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;
|
||||
@@ -58,56 +50,6 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null;
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -121,14 +63,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.invokeV8<V8ValueString>("generate").value;
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-87
@@ -3,7 +3,6 @@ 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
|
||||
@@ -16,19 +15,11 @@ 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.logging.Logger
|
||||
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 {
|
||||
@@ -66,56 +57,6 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null;
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -127,7 +68,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.invokeV8<V8ValueString>("generate").value;
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,7 +76,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
_obj.invokeString("generate");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,32 +116,6 @@ 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();
|
||||
|
||||
+1
-3
@@ -9,8 +9,6 @@ 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 {
|
||||
@@ -47,7 +45,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
+2
-3
@@ -16,7 +16,6 @@ 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
|
||||
@@ -65,7 +64,7 @@ abstract class JSSource {
|
||||
return@isBusyWith null;
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||
_obj.invokeV8("getRequestModifier", arrayOf<Any>());
|
||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
@@ -79,7 +78,7 @@ abstract class JSSource {
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] requesting");
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
|
||||
_obj.invokeV8("getRequestExecutor", arrayOf<Any>());
|
||||
_obj.invoke("getRequestExecutor", arrayOf<Any>());
|
||||
};
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] received");
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ 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
|
||||
@@ -26,7 +25,7 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
||||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
||||
+2
-5
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
@@ -18,7 +19,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
@@ -52,10 +53,6 @@ class LocalVideoDetails: IPlatformVideoDetails {
|
||||
override val isLive: Boolean = false;
|
||||
override val isShort: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
constructor(file: File) {
|
||||
id = PlatformID("Local", file.path, "LOCAL")
|
||||
name = file.name;
|
||||
|
||||
+4
-14
@@ -1,23 +1,13 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
|
||||
constructor(video: LocalVideoFileSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(video: LocalVideoContentSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>) {
|
||||
this.videoSources = videoSources;
|
||||
}
|
||||
class LocalVideoMuxedSourceDescriptor(
|
||||
private val video: LocalVideoFileSource
|
||||
) : VideoMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN
|
||||
override val original: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioFileSource: IAudioSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN;
|
||||
override val original: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import java.io.File
|
||||
|
||||
class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
-3
@@ -20,10 +20,7 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
+7
-5
@@ -7,12 +7,12 @@ import java.util.stream.IntStream
|
||||
* A Content MultiPager that returns results based on a specified distribution
|
||||
* TODO: Merge all basic distribution pagers
|
||||
*/
|
||||
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
|
||||
private val dist : HashMap<IPager<T>, Float>;
|
||||
private val distConsumed : HashMap<IPager<T>, Float>;
|
||||
private val dist : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
|
||||
|
||||
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
|
||||
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
|
||||
val distTotal = pagers.values.sum();
|
||||
dist = HashMap();
|
||||
|
||||
@@ -25,7 +25,7 @@ class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
|
||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
||||
if(options.size == 0)
|
||||
return -1;
|
||||
var bestIndex = 0;
|
||||
@@ -42,4 +42,6 @@ class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+179
-125
@@ -15,55 +15,60 @@ import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
class AirPlay1CastingDevice : CastingDevice {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = false;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override val canSetVolume: Boolean get() = false
|
||||
override val canSetSpeed: Boolean get() = true
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
var addresses: Array<InetAddress>? = null
|
||||
var port: Int = 0
|
||||
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _scopeIO: CoroutineScope? = null
|
||||
private var _started: Boolean = false
|
||||
private var _sessionId: String? = null
|
||||
private val _client = ManagedHttpClient()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
this.name = name
|
||||
this.addresses = addresses
|
||||
this.port = port
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
this.name = deviceInfo.name
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray()
|
||||
this.port = deviceInfo.port
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
return addresses?.toList() ?: listOf()
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)")
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "loadContent called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
setTime(resumePosition)
|
||||
setDuration(duration)
|
||||
if (resumePosition > 0.0) {
|
||||
val pos = resumePosition / duration;
|
||||
val pos = resumePosition / duration
|
||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos")
|
||||
} else {
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0")
|
||||
}
|
||||
|
||||
if (speed != null) {
|
||||
@@ -72,117 +77,157 @@ class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
throw NotImplementedError();
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}");
|
||||
Logger.i(TAG, "seekVideo()-> $timeSeconds")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "seekVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}")
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
post("rate?value=1.000000");
|
||||
Logger.i(TAG, "resumeVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "resumeVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = true
|
||||
post("rate?value=1.000000")
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = false;
|
||||
post("rate?value=0.000000");
|
||||
Logger.i(TAG, "pauseVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "pauseVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying = false
|
||||
post("rate?value=0.000000")
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("stop");
|
||||
Logger.i(TAG, "stopVideo()")
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "stopVideo called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
post("stop")
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
post("stop");
|
||||
stop();
|
||||
Logger.i(TAG, "stopCasting()")
|
||||
if (_sessionId != null) {
|
||||
post("stop")
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
val adrs = addresses ?: return
|
||||
if (_started) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
_started = true;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
_started = true
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
Logger.i(TAG, "Starting...")
|
||||
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port)
|
||||
if (connectedSocket == null) {
|
||||
delay(1000);
|
||||
continue;
|
||||
Logger.i(TAG, "Unable to connect yet; retrying in 1s.")
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
_sessionId = UUID.randomUUID().toString();
|
||||
break;
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
_sessionId = UUID.randomUUID().toString()
|
||||
|
||||
val probeSuccess = get("server-info") != null
|
||||
connectedSocket.close()
|
||||
|
||||
if (!probeSuccess) {
|
||||
Logger.w(TAG, "Handshake (GET /server-info) failed; retrying")
|
||||
_sessionId = null
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Handshake successful. SessionId=$_sessionId")
|
||||
break
|
||||
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val progressInfo = getProgress();
|
||||
val progressInfo = getProgress()
|
||||
if (progressInfo == null) {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
||||
delay(1000);
|
||||
continue;
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.")
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ")
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue
|
||||
setTime(progress)
|
||||
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ")
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
||||
setDuration(duration);
|
||||
delay(1000);
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue
|
||||
setDuration(duration)
|
||||
delay(1000)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
||||
}
|
||||
@@ -190,103 +235,111 @@ class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
Logger.i(TAG, "Started.")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
Logger.i(TAG, "Stopping...")
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = null;
|
||||
_sessionId = null
|
||||
usedRemoteAddress = null
|
||||
localAddress = null
|
||||
_started = false
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = null
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (_sessionId == null) {
|
||||
Logger.w(TAG, "changeSpeed called before session established. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
setSpeed(speed)
|
||||
post("rate?value=$speed")
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port)
|
||||
}
|
||||
|
||||
private fun getProgress(): String? {
|
||||
val info = get("scrub");
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
||||
return info;
|
||||
val info = get("scrub")
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}")
|
||||
return info
|
||||
}
|
||||
|
||||
private fun getPlaybackInfo(): String? {
|
||||
val playbackInfo = get("playback-info");
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
||||
return playbackInfo;
|
||||
val playbackInfo = get("playback-info")
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}")
|
||||
return playbackInfo
|
||||
}
|
||||
|
||||
private fun getServerInfo(): String? {
|
||||
val serverInfo = get("server-info");
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
||||
return serverInfo;
|
||||
val serverInfo = get("server-info")
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}")
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
private fun post(path: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
val sessionId = _sessionId ?: return false
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"Content-Length" to "0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "POST $url");
|
||||
val response = _client.post(url, headers);
|
||||
Logger.i(TAG, "POST $url")
|
||||
val response = _client.post(url, headers)
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path");
|
||||
return false;
|
||||
Logger.w(TAG, "Failed to POST $path")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
val sessionId = _sessionId ?: return false
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId,
|
||||
"Content-Type" to contentType
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "POST $url:\n$body");
|
||||
val response = _client.post(url, body, headers);
|
||||
Logger.i(TAG, "POST $url:\n$body")
|
||||
val response = _client.post(url, body, headers)
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
Logger.w(TAG, "POST /$path failed (HTTP ${response.code})")
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path $body");
|
||||
return false;
|
||||
Logger.w(TAG, "Failed to POST $path $body")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(path: String): String? {
|
||||
val sessionId = _sessionId ?: return null;
|
||||
val sessionId = _sessionId ?: return null
|
||||
|
||||
try {
|
||||
val headers = hashMapOf(
|
||||
@@ -294,37 +347,38 @@ class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
"Content-Length" to "0",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
)
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}"
|
||||
|
||||
Logger.i(TAG, "GET $url");
|
||||
val response = _client.get(url, headers);
|
||||
Logger.i(TAG, "GET $url")
|
||||
val response = _client.get(url, headers)
|
||||
if (!response.isOk) {
|
||||
return null;
|
||||
Logger.w(TAG, "GET /$path failed (HTTP ${response.code})")
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return response.body.string();
|
||||
return response.body.string()
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to GET $path");
|
||||
return null;
|
||||
Logger.w(TAG, "Failed to GET $path")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
_scopeIO?.launch { action() }
|
||||
return true
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "AirPlayCastingDevice";
|
||||
val TAG = "AirPlay1CastingDevice"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.dd.plist.NSDictionary
|
||||
import com.dd.plist.NSNumber
|
||||
import com.dd.plist.NSString
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.stripLeadingZero
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.bouncycastle.crypto.digests.SHA512Digest
|
||||
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
|
||||
import org.bouncycastle.crypto.params.*
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
class AirPlay2CastingDevice : CastingDevice {
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY2
|
||||
override val isReady: Boolean get() = name != null && addresses?.isNotEmpty() == true && port != 0
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override val canSetVolume: Boolean get() = true
|
||||
override val canSetSpeed: Boolean get() = true
|
||||
|
||||
var addresses: Array<InetAddress>? = null
|
||||
var port: Int = 0
|
||||
|
||||
private val _pairingDataHandler: IPairingDataHandler
|
||||
private var _scopeIO: CoroutineScope? = null
|
||||
private var _started: Boolean = false
|
||||
@Volatile private var _paired: Boolean = false
|
||||
private var _state: AirPlaySenderState = AirPlaySenderState.NOT_CONNECTED
|
||||
private var _srpClient: SRPClient? = null
|
||||
private var _pin: String? = null
|
||||
private var _sessionKey: ByteArray? = null
|
||||
private var _devicePrivateKey: ByteArray? = null
|
||||
private var _devicePublicKey: ByteArray? = null
|
||||
private var _verifierPrivateKey: ByteArray? = null
|
||||
private var _verifierPublicKey: ByteArray? = null
|
||||
private var _accessoryLtpk: ByteArray? = null
|
||||
private var _accessoryCurvePublic: ByteArray? = null
|
||||
private var _accessorySharedKey: ByteArray? = null
|
||||
private var _isEncrypted: Boolean = false
|
||||
private var _outgoingKey: ByteArray? = null
|
||||
private var _incomingKey: ByteArray? = null
|
||||
private var _outCount: Int = 0
|
||||
private var _inCount: Int = 0
|
||||
private var _cseq = 0
|
||||
private val _httpClient: OkHttpClient = OkHttpClient.Builder().cookieJar(JavaNetCookieJar(CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) })).build()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPlay2CastingDevice"
|
||||
private const val DEVICE_ID = "C9635ED0964902E0"
|
||||
private const val CONTENT_TYPE = "application/octet-stream"
|
||||
private const val TAG_LENGTH = 16
|
||||
private const val MAX_BLOCK_LENGTH = 0x400
|
||||
val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" +
|
||||
"020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" +
|
||||
"4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" +
|
||||
"EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" +
|
||||
"98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" +
|
||||
"9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" +
|
||||
"E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
|
||||
"3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" +
|
||||
"A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" +
|
||||
"ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" +
|
||||
"D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" +
|
||||
"08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray())
|
||||
val g = BigInteger(1, "05".hexToByteArray())
|
||||
}
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int, pairingDataHandler: IPairingDataHandler) {
|
||||
this.name = name
|
||||
this.addresses = addresses
|
||||
this.port = port
|
||||
_pairingDataHandler = pairingDataHandler
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo, pairingDataHandler: IPairingDataHandler) {
|
||||
this.name = deviceInfo.name
|
||||
this.addresses = deviceInfo.addresses.mapNotNull { it.toInetAddress() }.toTypedArray()
|
||||
this.port = deviceInfo.port
|
||||
_pairingDataHandler = pairingDataHandler
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = addresses?.toList() ?: emptyList()
|
||||
|
||||
override fun providePairingPin(pin: String?) {
|
||||
Logger.i(TAG, "Pairing PIN provided $pin")
|
||||
_pin = pin
|
||||
_scopeIO?.launch(Dispatchers.IO) {
|
||||
performPair(pin)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) return
|
||||
val adrs = addresses ?: return
|
||||
|
||||
_started = true
|
||||
_paired = false
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
Logger.i(TAG, "Starting AirPlay2 device...")
|
||||
|
||||
_scopeIO?.launch(Dispatchers.IO) {
|
||||
usedRemoteAddress = adrs.firstOrNull { addr ->
|
||||
try {
|
||||
val socket = java.net.Socket(addr, port)
|
||||
localAddress = socket.localAddress
|
||||
socket.close()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed connecting to $addr:$port", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (usedRemoteAddress == null) {
|
||||
Logger.w(TAG, "Could not connect to any address.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Connected to ${usedRemoteAddress}:${port}")
|
||||
if (!_paired) {
|
||||
performPairSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping AirPlay2 device...")
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
_paired = false
|
||||
_started = false
|
||||
_scopeIO?.cancel()
|
||||
_scopeIO = null
|
||||
Logger.i(TAG, "AirPlay2 device stopped.")
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
) {
|
||||
Logger.i(TAG, "loadVideo: contentId=$contentId, resumePosition=$resumePosition")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
Logger.i(TAG, "seekVideo: $timeSeconds")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
Logger.i(TAG, "resumeVideo")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
Logger.i(TAG, "pauseVideo")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
Logger.i(TAG, "stopVideo")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
stopVideo()
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
Logger.i(TAG, "changeVolume: $volume")
|
||||
if (!isReady || !_paired) return
|
||||
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
Logger.i(TAG, "changeSpeed: $speed")
|
||||
if (!isReady || !_paired) return
|
||||
//TODO
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(
|
||||
name!!,
|
||||
CastProtocolType.AIRPLAY2,
|
||||
addresses!!.mapNotNull { it.hostAddress }.toTypedArray(),
|
||||
port
|
||||
)
|
||||
}
|
||||
|
||||
private fun getUrl(endpoint: String): String {
|
||||
return "http://${usedRemoteAddress?.hostAddress}:$port$endpoint"
|
||||
}
|
||||
|
||||
private fun performPairSetup() {
|
||||
/*Logger.i(TAG, "Starting pair-setup...")
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_PIN_START
|
||||
val pinResult = postHttp("/pair-pin-start", ByteArray(0), null)
|
||||
if (pinResult == true) {
|
||||
Logger.i(TAG, "Waiting for PIN...")
|
||||
onPairingPinRequired.emit()
|
||||
} else {
|
||||
Logger.w(TAG, "Failed to show PIN, attempting pair without PIN")
|
||||
_scopeIO?.launch(Dispatchers.IO) { performPair(null) }
|
||||
}*/
|
||||
|
||||
_scopeIO?.launch(Dispatchers.IO) { performPair(null) }
|
||||
}
|
||||
|
||||
private fun performPair(pin: String?) {
|
||||
Logger.i(TAG, "Performing pair with PIN $pin")
|
||||
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP1
|
||||
val username = "Pair-Setup"
|
||||
val password = pin ?: "3939"
|
||||
_srpClient = SRPClient(N, g, username, password)
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value))
|
||||
val methodItem = TLV8Item(TLV8Tag.METHOD, ubyteArrayOf(PairingMethod.PAIR_SETUP.value))
|
||||
val tlvItems = listOf(stateItem, methodItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", encodedTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to initiate pair-setup")
|
||||
}
|
||||
}
|
||||
|
||||
private fun continuePairSetup(responseData: ByteArray) {
|
||||
if (responseData.isEmpty()) {
|
||||
pairingDidFail("Server response data is empty")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Response: " + TLV8Item.decodeAsString(responseData.asUByteArray()))
|
||||
|
||||
val fields = TLV8Item.decodeAndReassembleWithLogging(responseData.asUByteArray())
|
||||
val errorBytes = fields[TLV8Tag.ERROR]
|
||||
if (errorBytes?.isNotEmpty() == true) {
|
||||
val errorCode = errorBytes[0].toUByte().toInt()
|
||||
if (errorCode == 0x03) {
|
||||
val backoffBytes = fields[TLV8Tag.RETRY_DELAY]
|
||||
val backoffSeconds = ByteBuffer.wrap(backoffBytes).order(ByteOrder.LITTLE_ENDIAN).short
|
||||
pairingDidFail("Pairing backoff requested, should retry in ${backoffSeconds}s")
|
||||
} else {
|
||||
pairingDidFail("Pairing failed with error code $errorCode")
|
||||
}
|
||||
return
|
||||
}
|
||||
val stateBytes = fields[TLV8Tag.STATE]
|
||||
if (stateBytes == null || stateBytes.isEmpty()) {
|
||||
pairingDidFail("State item is missing")
|
||||
return
|
||||
}
|
||||
val remoteState = stateBytes[0].toUByte()
|
||||
Logger.i(TAG, "Transitioned to state ${remoteState}")
|
||||
|
||||
when {
|
||||
// ───── SETUP PHASE ─────
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP1 && remoteState == PairingState.M2.value -> pairSetupM2M3(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP2 && remoteState == PairingState.M4.value -> pairSetupM4M5(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_SETUP3 && remoteState == PairingState.M6.value -> pairVerifyM1(fields)
|
||||
|
||||
// ───── VERIFY PHASE ─────
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY1 && remoteState == PairingState.M2.value -> pairVerifyM2(fields)
|
||||
_state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY2 && remoteState == PairingState.M4.value -> {
|
||||
_isEncrypted = true
|
||||
setCiphers()
|
||||
_state = AirPlaySenderState.READY_TO_PLAY
|
||||
_paired = true
|
||||
pairingDidFinish()
|
||||
}
|
||||
|
||||
else -> pairingDidFail("Unexpected STATE=$remoteState when in $_state")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun pairSetupM2M3(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP2
|
||||
|
||||
val saltBytes = fields[TLV8Tag.SALT]
|
||||
val BBytes = fields[TLV8Tag.PUBLIC_KEY]
|
||||
if (saltBytes == null || BBytes == null) {
|
||||
pairingDidFail("Salt or public key is missing")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized")
|
||||
val ABytes = client.srp_user_start_authentication()
|
||||
|
||||
val M1Bytes = client.srp_user_process_challenge(saltBytes, BBytes)
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value))
|
||||
val aBytes = ABytes.toByteArray().stripLeadingZero().asUByteArray()
|
||||
val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, aBytes)
|
||||
val m1Bytes = M1Bytes.asUByteArray()
|
||||
val proofItem = TLV8Item(TLV8Tag.PROOF, m1Bytes)
|
||||
val tlvItems = listOf(stateItem, pkItem, proofItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", encodedTlv, headers)
|
||||
if (response == null) {
|
||||
pairingDidFail("M2→M3: no HTTP response (connection error)")
|
||||
return
|
||||
}
|
||||
|
||||
val code = response.code
|
||||
val bodyBytes = response.body?.bytes()
|
||||
if (response.isSuccessful && bodyBytes != null) {
|
||||
continuePairSetup(bodyBytes)
|
||||
} else {
|
||||
pairingDidFail("M2→M3 failed: HTTP $code, body=${bodyBytes?.toHexString()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("SRP calculation failed.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairSetupM4M5(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_SETUP3
|
||||
|
||||
val proofBytes = fields[TLV8Tag.PROOF]
|
||||
if (proofBytes == null) {
|
||||
pairingDidFail("Proof is missing")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized")
|
||||
val verified = client.srp_user_verify_session(proofBytes)
|
||||
if (!verified) {
|
||||
pairingDidFail("Server authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
val K = client.getSessionKey() ?: throw IllegalStateException("Session key not computed")
|
||||
_sessionKey = K
|
||||
|
||||
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val edPriv = Ed25519PrivateKeyParameters(seed, 0)
|
||||
val edPub = edPriv.generatePublicKey()
|
||||
|
||||
_devicePrivateKey = seed
|
||||
_devicePublicKey = edPub.encoded
|
||||
|
||||
val deviceX = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Controller-Sign-Salt".toByteArray(Charsets.US_ASCII),
|
||||
"Pair-Setup-Controller-Sign-Info".toByteArray(Charsets.US_ASCII),
|
||||
32
|
||||
)
|
||||
|
||||
val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.US_ASCII)
|
||||
val deviceInfo = concat(deviceX, deviceIDBytes, edPub.encoded)
|
||||
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, edPriv)
|
||||
signer.update(deviceInfo, 0, deviceInfo.size)
|
||||
val signature = signer.generateSignature()
|
||||
|
||||
val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray())
|
||||
val publicKeyItem = TLV8Item(TLV8Tag.PUBLIC_KEY, edPub.encoded.asUByteArray())
|
||||
val sigItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray())
|
||||
val tlvItems = listOf(identifierItem, publicKeyItem, sigItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val sessionKey2 = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Encrypt-Salt".toByteArray(Charsets.US_ASCII),
|
||||
"Pair-Setup-Encrypt-Info".toByteArray(Charsets.US_ASCII),
|
||||
32
|
||||
)
|
||||
|
||||
val bcNonce = ByteArray(4) { 0x00 } + "PS-Msg05".toByteArray(Charsets.UTF_8)
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(
|
||||
sessionKey2,
|
||||
bcNonce,
|
||||
ByteArray(0),
|
||||
encodedTlv
|
||||
)
|
||||
val encryptedData = ciphertext + mac
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M5.value))
|
||||
val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray())
|
||||
val responseItems = listOf(stateItem, encryptedDataItem)
|
||||
val responseTlv = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to responseTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-setup", responseTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process M4→M5")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Error in M4→M5.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairVerifyM1(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY1
|
||||
|
||||
val encryptedField = fields[TLV8Tag.ENCRYPTED_DATA]
|
||||
if (encryptedField == null) {
|
||||
pairingDidFail("Encrypted data missing")
|
||||
return
|
||||
}
|
||||
val encryptedTlvData = encryptedField.copyOfRange(0, encryptedField.size - TAG_LENGTH)
|
||||
val tagData = encryptedField.copyOfRange(encryptedField.size - TAG_LENGTH, encryptedField.size)
|
||||
|
||||
try {
|
||||
val K = _sessionKey ?: throw IllegalStateException("No valid session key")
|
||||
val sessionKey2 = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Encrypt-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Setup-Encrypt-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val nonce = ByteArray(4) { 0x00 } + "PS-Msg06".toByteArray(Charsets.UTF_8)
|
||||
val decryptedTlv = chacha20Poly1305Decrypt(
|
||||
sessionKey2,
|
||||
nonce,
|
||||
ByteArray(0),
|
||||
encryptedTlvData,
|
||||
tagData
|
||||
) ?: throw IllegalStateException("Decryption failed")
|
||||
|
||||
val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray())
|
||||
val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray()
|
||||
val accessoryLtpkBytes = accessoryItems.find { it.tag == TLV8Tag.PUBLIC_KEY }?.value?.asByteArray()
|
||||
val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray()
|
||||
|
||||
if (accessoryIdBytes == null || accessoryLtpkBytes == null || accessorySigBytes == null) {
|
||||
pairingDidFail("Accessory data incomplete")
|
||||
return
|
||||
}
|
||||
_accessoryLtpk = accessoryLtpkBytes
|
||||
val accessoryX = hkdfExtractExpand(
|
||||
K,
|
||||
"Pair-Setup-Accessory-Sign-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Setup-Accessory-Sign-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val accessoryInfo = concat(accessoryX, accessoryIdBytes, accessoryLtpkBytes)
|
||||
val verifier = Ed25519Signer()
|
||||
val pubParam = Ed25519PublicKeyParameters(accessoryLtpkBytes, 0)
|
||||
verifier.init(false, pubParam)
|
||||
verifier.update(accessoryInfo, 0, accessoryInfo.size)
|
||||
if (!verifier.verifySignature(accessorySigBytes)) {
|
||||
pairingDidFail("Accessory signature not verified")
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "Accessory signature is valid!")
|
||||
|
||||
val curvePriv = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val curvePub = X25519PrivateKeyParameters(curvePriv, 0)
|
||||
.generatePublicKey()
|
||||
.encoded
|
||||
_verifierPrivateKey = curvePriv
|
||||
_verifierPublicKey = curvePub
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value))
|
||||
val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, curvePub.asUByteArray())
|
||||
val responseItems = listOf(stateItem, pkItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedTlv.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-verify", encodedTlv, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process pair-verify M1")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Pair-verify M1 failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun pairVerifyM2(fields: Map<TLV8Tag, ByteArray>) {
|
||||
_state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY2
|
||||
|
||||
val accessoryCurvePubBytes = fields[TLV8Tag.PUBLIC_KEY]
|
||||
val accessoryEncryptedField = fields[TLV8Tag.ENCRYPTED_DATA]
|
||||
if (accessoryCurvePubBytes == null || accessoryEncryptedField == null) {
|
||||
pairingDidFail("Public key or encrypted data missing")
|
||||
return
|
||||
}
|
||||
_accessoryCurvePublic = accessoryCurvePubBytes
|
||||
|
||||
val encryptedTlvData = accessoryEncryptedField.copyOfRange(0, accessoryEncryptedField.size - TAG_LENGTH)
|
||||
val tagData = accessoryEncryptedField.copyOfRange(accessoryEncryptedField.size - TAG_LENGTH, accessoryEncryptedField.size)
|
||||
|
||||
try {
|
||||
val privParam = X25519PrivateKeyParameters(_verifierPrivateKey!!, 0)
|
||||
val pubParam = X25519PublicKeyParameters(accessoryCurvePubBytes, 0)
|
||||
val sharedSecret = ByteArray(32)
|
||||
privParam.generateSecret(pubParam, sharedSecret, 0)
|
||||
_accessorySharedKey = sharedSecret
|
||||
|
||||
val sessionKey = hkdfExtractExpand(
|
||||
sharedSecret,
|
||||
"Pair-Verify-Encrypt-Salt".toByteArray(Charsets.UTF_8),
|
||||
"Pair-Verify-Encrypt-Info".toByteArray(Charsets.UTF_8),
|
||||
32
|
||||
)
|
||||
|
||||
val nonce = ByteArray(4) { 0x00 } + "PV-Msg02".toByteArray(Charsets.UTF_8)
|
||||
val decryptedTlv = chacha20Poly1305Decrypt(
|
||||
sessionKey,
|
||||
nonce,
|
||||
ByteArray(0),
|
||||
encryptedTlvData,
|
||||
tagData
|
||||
) ?: throw IllegalStateException("Decryption failed")
|
||||
|
||||
val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray())
|
||||
val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray()
|
||||
val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray()
|
||||
if (accessoryIdBytes == null || accessorySigBytes == null) {
|
||||
pairingDidFail("Accessory data incomplete")
|
||||
return
|
||||
}
|
||||
|
||||
val accessoryInfo = concat(
|
||||
accessoryCurvePubBytes,
|
||||
accessoryIdBytes,
|
||||
_verifierPublicKey!!
|
||||
)
|
||||
val verifier = Ed25519Signer()
|
||||
verifier.init(false, Ed25519PublicKeyParameters(_accessoryLtpk!!, 0))
|
||||
verifier.update(accessoryInfo, 0, accessoryInfo.size)
|
||||
if (!verifier.verifySignature(accessorySigBytes)) {
|
||||
pairingDidFail("Accessory signature not verified")
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "Accessory signature is valid!")
|
||||
|
||||
val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.UTF_8)
|
||||
val deviceInfo = concat(
|
||||
_verifierPublicKey!!,
|
||||
deviceIDBytes,
|
||||
accessoryCurvePubBytes
|
||||
)
|
||||
val signer = Ed25519Signer()
|
||||
val edPriv = Ed25519PrivateKeyParameters(_devicePrivateKey!!, 0)
|
||||
signer.init(true, edPriv)
|
||||
signer.update(deviceInfo, 0, deviceInfo.size)
|
||||
val signature = signer.generateSignature()
|
||||
|
||||
val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray())
|
||||
val signatureItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray())
|
||||
val tlvItems = listOf(identifierItem, signatureItem)
|
||||
val encodedTlv = TLV8Item.encodeWithLogging(tlvItems)
|
||||
|
||||
val nonce2 = ByteArray(4) { 0x00 } + "PV-Msg03".toByteArray(Charsets.UTF_8)
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(
|
||||
sessionKey,
|
||||
nonce2,
|
||||
ByteArray(0),
|
||||
encodedTlv
|
||||
)
|
||||
val encryptedData = ciphertext + mac
|
||||
|
||||
val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value))
|
||||
val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray())
|
||||
val responseItems = listOf(stateItem, encryptedDataItem)
|
||||
val encodedResponse = TLV8Item.encodeWithLogging(responseItems)
|
||||
|
||||
val headers = mapOf(
|
||||
"Content-Type" to CONTENT_TYPE,
|
||||
"Content-Length" to encodedResponse.size.toString()
|
||||
)
|
||||
val response = postHttpWithResponse("/pair-verify", encodedResponse, headers)
|
||||
if (response?.isSuccessful == true) {
|
||||
response.body?.bytes()?.let { continuePairSetup(it) }
|
||||
} else {
|
||||
pairingDidFail("Failed to process pair-verify M2")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pairingDidFail("Pair-verify M2 failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCiphers() {
|
||||
val sharedKey = _accessorySharedKey ?: return
|
||||
val prk = hkdfExtractExpand(sharedKey, "Control-Salt".encodeToByteArray(), null, 64)
|
||||
_outgoingKey = hkdfExtractExpand(prk, "Control-Write-Encryption-Key".encodeToByteArray(), null, 32)
|
||||
_incomingKey = hkdfExtractExpand(prk, "Control-Read-Encryption-Key".encodeToByteArray(), null, 32)
|
||||
}
|
||||
|
||||
/*private fun postEncrypted(
|
||||
path: String,
|
||||
plaintext: ByteArray
|
||||
): Boolean {
|
||||
val encrypted = encryptData(plaintext)
|
||||
val req = Request.Builder()
|
||||
.url(getUrl(path))
|
||||
.post(encrypted.toRequestBody(CONTENT_TYPE.toMediaType()))
|
||||
.headers(
|
||||
Headers.headersOf(
|
||||
"User-Agent" to "AirPlay/381.13",
|
||||
"X-Apple-HKP" to "3",
|
||||
"X-Apple-Client-Name" to "Grayjay"
|
||||
) )
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Encrypted POST failed to $path", e)
|
||||
false
|
||||
}
|
||||
}*/
|
||||
|
||||
private fun Map<String,Any>.toNSDictionary(): NSDictionary {
|
||||
val dict = NSDictionary()
|
||||
forEach { (k,v) ->
|
||||
when (v) {
|
||||
is String -> dict[k] = NSString(v)
|
||||
is Double -> dict[k] = NSNumber(v)
|
||||
is Long -> dict[k] = NSNumber(v)
|
||||
is Int -> dict[k] = NSNumber(v)
|
||||
is Boolean -> dict[k] = if (v) NSNumber(true) else NSNumber(false)
|
||||
else -> throw IllegalArgumentException("Unsupported plist value type: ${v.javaClass}")
|
||||
}
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private fun encryptData(data: ByteArray): ByteArray {
|
||||
if (!_isEncrypted || _outgoingKey == null) return data
|
||||
val result = ByteArrayOutputStream()
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val length = minOf(data.size - offset, MAX_BLOCK_LENGTH)
|
||||
val blockData = data.copyOfRange(offset, offset + length)
|
||||
val lengthData = ByteBuffer.allocate(2).putShort(length.toShort()).array()
|
||||
val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_outCount.toLong()).array()
|
||||
val (ciphertext, mac) = chacha20Poly1305Encrypt(_outgoingKey!!, nonce, lengthData, blockData)
|
||||
result.write(lengthData)
|
||||
result.write(ciphertext)
|
||||
result.write(mac)
|
||||
offset += length
|
||||
_outCount++
|
||||
}
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
private fun decryptData(data: ByteArray): ByteArray? {
|
||||
if (!_isEncrypted || _incomingKey == null || data.size < 2 + TAG_LENGTH) return null
|
||||
val length = ByteBuffer.wrap(data, 0, 2).short.toInt() and 0xFFFF
|
||||
if (data.size < 2 + length + TAG_LENGTH) return null
|
||||
val blockData = data.copyOfRange(2, 2 + length)
|
||||
val mac = data.copyOfRange(2 + length, 2 + length + TAG_LENGTH)
|
||||
val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_inCount.toLong()).array()
|
||||
val plaintext = chacha20Poly1305Decrypt(_incomingKey!!, nonce, byteArrayOf(), blockData, mac)
|
||||
if (plaintext != null) _inCount++
|
||||
return plaintext
|
||||
}
|
||||
|
||||
private fun pairingDidFail(message: String, e: Throwable? = null) {
|
||||
_state = AirPlaySenderState.PAIRING_FAILED
|
||||
if (e != null)
|
||||
Logger.e(TAG, "Pairing failed with message '${message}'.", e)
|
||||
else
|
||||
Logger.e(TAG, "Pairing failed with message '${message}'.")
|
||||
}
|
||||
|
||||
private fun pairingDidFinish() {
|
||||
Logger.i(TAG, "Pairing succeeded. Device is ready.")
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
_state = AirPlaySenderState.READY_TO_PLAY
|
||||
_paired = true
|
||||
|
||||
//TODO: Do something?
|
||||
}
|
||||
|
||||
private fun postHttp(path: String, bodyBytes: ByteArray, contentType: String?): Boolean? {
|
||||
val url = getUrl(path)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(bodyBytes.toRequestBody(contentType?.toMediaType()))
|
||||
.header("User-Agent", "AirPlay/381.13")
|
||||
.header("X-Apple-HKP", "3")
|
||||
.header("CSeq", (_cseq++).toString())
|
||||
.apply { if (contentType != null) header("Content-Type", contentType) }
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(request).execute().use { response ->
|
||||
response.isSuccessful
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "HTTP POST failed: $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun postHttpWithResponse(path: String, bodyBytes: ByteArray, headers: Map<String, String>?): Response? {
|
||||
val url = getUrl(path)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(bodyBytes.toRequestBody(headers?.get("Content-Type")?.toMediaType()))
|
||||
.header("User-Agent", "AirPlay/381.13")
|
||||
.header("X-Apple-HKP", "3")
|
||||
.header("X-Apple-Client-Name", "Grayjay")
|
||||
.apply {
|
||||
headers?.forEach { (k, v) -> header(k, v) }
|
||||
}
|
||||
.build()
|
||||
|
||||
return try {
|
||||
_httpClient.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "HTTP POST failed: $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hkdfExtractExpand(ikm: ByteArray, salt: ByteArray?, info: ByteArray?, length: Int): ByteArray {
|
||||
val hkdf = HKDFBytesGenerator(SHA512Digest())
|
||||
val params = HKDFParameters(ikm, salt, info)
|
||||
hkdf.init(params)
|
||||
val output = ByteArray(length)
|
||||
hkdf.generateBytes(output, 0, length)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun chacha20Poly1305Encrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val aead = ChaCha20Poly1305()
|
||||
aead.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))
|
||||
|
||||
val output = ByteArray(plaintext.size + 16)
|
||||
var offset = aead.processBytes(plaintext, 0, plaintext.size, output, 0)
|
||||
aead.doFinal(output, offset)
|
||||
|
||||
val ciphertext = output.copyOf(plaintext.size)
|
||||
val tag = output.copyOfRange(plaintext.size, output.size)
|
||||
return Pair(ciphertext, tag)
|
||||
}
|
||||
|
||||
private fun chacha20Poly1305Decrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray, mac: ByteArray): ByteArray? {
|
||||
val aead = ChaCha20Poly1305()
|
||||
aead.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))
|
||||
|
||||
val input = ciphertext + mac
|
||||
val output = ByteArray(ciphertext.size)
|
||||
var len = aead.processBytes(input, 0, input.size, output, 0)
|
||||
return try {
|
||||
aead.doFinal(output, len)
|
||||
output
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun concat(vararg arrays: ByteArray): ByteArray {
|
||||
val totalLength = arrays.sumOf { it.size }
|
||||
val result = ByteArray(totalLength)
|
||||
var offset = 0
|
||||
for (array in arrays) {
|
||||
System.arraycopy(array, 0, result, offset, array.size)
|
||||
offset += array.size
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum class AirPlaySenderState {
|
||||
NOT_CONNECTED,
|
||||
WAITING_ON_PAIR_PIN_START,
|
||||
WAITING_ON_PAIR_SETUP1,
|
||||
WAITING_ON_PAIR_SETUP2,
|
||||
WAITING_ON_PAIR_SETUP3,
|
||||
WAITING_ON_PAIR_VERIFY1,
|
||||
WAITING_ON_PAIR_VERIFY2,
|
||||
READY_TO_PLAY,
|
||||
CANCELLED,
|
||||
PAIRING_FAILED
|
||||
}
|
||||
|
||||
enum class PairingState(val value: UByte) {
|
||||
M1(1u),
|
||||
M2(2u),
|
||||
M3(3u),
|
||||
M4(4u),
|
||||
M5(5u),
|
||||
M6(6u)
|
||||
}
|
||||
|
||||
enum class PairingMethod(val value: UByte) {
|
||||
PAIR_SETUP(0u),
|
||||
PAIR_SETUP_WITH_AUTH(1u),
|
||||
PAIR_VERIFY(2u),
|
||||
ADD_PAIRING(3u),
|
||||
REMOVE_PAIRING(4u),
|
||||
LIST_PAIRINGS(5u)
|
||||
}
|
||||
@@ -1,79 +1,160 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.net.InetAddress
|
||||
|
||||
abstract class CastingDevice {
|
||||
abstract val isReady: Boolean
|
||||
abstract val usedRemoteAddress: InetAddress?
|
||||
abstract val localAddress: InetAddress?
|
||||
abstract val name: String?
|
||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
abstract val onPlayChanged: Event1<Boolean>
|
||||
abstract val onTimeChanged: Event1<Double>
|
||||
abstract val onDurationChanged: Event1<Double>
|
||||
abstract val onVolumeChanged: Event1<Double>
|
||||
abstract val onSpeedChanged: Event1<Double>
|
||||
abstract var connectionState: CastConnectionState
|
||||
abstract val protocolType: CastProtocolType
|
||||
abstract var isPlaying: Boolean
|
||||
abstract val expectedCurrentTime: Double
|
||||
abstract var speed: Double
|
||||
abstract var time: Double
|
||||
abstract var duration: Double
|
||||
abstract var volume: Double
|
||||
abstract fun canSetVolume(): Boolean
|
||||
abstract fun canSetSpeed(): Boolean
|
||||
|
||||
@Throws
|
||||
abstract fun resumePlayback()
|
||||
|
||||
@Throws
|
||||
abstract fun pausePlayback()
|
||||
|
||||
@Throws
|
||||
abstract fun stopPlayback()
|
||||
|
||||
@Throws
|
||||
abstract fun seekTo(timeSeconds: Double)
|
||||
|
||||
@Throws
|
||||
abstract fun changeVolume(timeSeconds: Double)
|
||||
|
||||
@Throws
|
||||
abstract fun changeSpeed(speed: Double)
|
||||
|
||||
@Throws
|
||||
abstract fun connect()
|
||||
|
||||
@Throws
|
||||
abstract fun disconnect()
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
|
||||
@Throws
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
@Throws
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
abstract fun ensureThreadStarted()
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST,
|
||||
AIRPLAY2;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var name: String? = null
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field
|
||||
field = value
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
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
|
||||
|
||||
if (changed) {
|
||||
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>()
|
||||
|
||||
abstract fun stopCasting()
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double)
|
||||
abstract fun stopVideo()
|
||||
abstract fun pauseVideo()
|
||||
abstract fun resumeVideo()
|
||||
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?)
|
||||
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?)
|
||||
open fun changeVolume(volume: Double) { throw NotImplementedError() }
|
||||
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
|
||||
|
||||
abstract fun start()
|
||||
abstract fun stop()
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.GenericKeyEvent
|
||||
import org.fcast.sender_sdk.GenericMediaEvent
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import java.net.InetAddress
|
||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||
import org.fcast.sender_sdk.DeviceConnectionState
|
||||
import org.fcast.sender_sdk.DeviceFeature
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
|
||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte()
|
||||
)
|
||||
)
|
||||
|
||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte(),
|
||||
addr.o5.toByte(),
|
||||
addr.o6.toByte(),
|
||||
addr.o7.toByte(),
|
||||
addr.o8.toByte(),
|
||||
addr.o9.toByte(),
|
||||
addr.o10.toByte(),
|
||||
addr.o11.toByte(),
|
||||
addr.o12.toByte(),
|
||||
addr.o13.toByte(),
|
||||
addr.o14.toByte(),
|
||||
addr.o15.toByte(),
|
||||
addr.o16.toByte()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
|
||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||
onConnectionStateChanged.emit(state)
|
||||
}
|
||||
|
||||
override fun volumeChanged(volume: Double) {
|
||||
onVolumeChanged.emit(volume)
|
||||
}
|
||||
|
||||
override fun timeChanged(time: Double) {
|
||||
onTimeChanged.emit(time)
|
||||
}
|
||||
|
||||
override fun playbackStateChanged(state: PlaybackState) {
|
||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||
}
|
||||
|
||||
override fun durationChanged(duration: Double) {
|
||||
onDurationChanged.emit(duration)
|
||||
}
|
||||
|
||||
override fun speedChanged(speed: Double) {
|
||||
onSpeedChanged.emit(speed)
|
||||
}
|
||||
|
||||
override fun sourceChanged(source: Source) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun keyEvent(event: GenericKeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: GenericMediaEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
override val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
override val name: String
|
||||
get() = device.name()
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
override val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
override val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
|
||||
override fun resumePlayback() = device.resumePlayback()
|
||||
override fun pausePlayback() = device.pausePlayback()
|
||||
override fun stopPlayback() = device.stopPlayback()
|
||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
override fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
override fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
override fun disconnect() = device.disconnect()
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
val info = device.getDeviceInfo()
|
||||
return CastingDeviceInfo(
|
||||
info.name,
|
||||
when (info.protocol) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
},
|
||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||
port = info.port.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata
|
||||
)
|
||||
)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Content(
|
||||
contentType = contentType,
|
||||
content = content,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
)
|
||||
)
|
||||
|
||||
override var connectionState = CastConnectionState.DISCONNECTED
|
||||
override val protocolType: CastProtocolType
|
||||
get() = when (device.castingProtocol()) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
}
|
||||
override var volume: Double = 1.0
|
||||
override var duration: Double = 0.0
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
override var time: Double = 0.0
|
||||
override var speed: Double = 0.0
|
||||
override var isPlaying: Boolean = false
|
||||
|
||||
override val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff
|
||||
}
|
||||
|
||||
init {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
if (newState == DeviceConnectionState.Disconnected) {
|
||||
try {
|
||||
Logger.i(TAG, "Stopping device")
|
||||
device.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to stop device: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||
eventHandler.onTimeChanged.subscribe {
|
||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||
time = it
|
||||
}
|
||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||
}
|
||||
|
||||
override fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDeviceLegacy {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
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;
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
abstract fun stopCasting();
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
open fun changeVolume(volume: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun changeSpeed(speed: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
}
|
||||
|
||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
||||
override val isReady: Boolean get() = inner.isReady
|
||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
||||
override val localAddress: InetAddress? get() = inner.localAddress
|
||||
override val name: String? get() = inner.name
|
||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||
override var connectionState: CastConnectionState
|
||||
get() = inner.connectionState
|
||||
set(_) = Unit
|
||||
override val protocolType: CastProtocolType get() = inner.protocol
|
||||
override var isPlaying: Boolean
|
||||
get() = inner.isPlaying
|
||||
set(_) = Unit
|
||||
override val expectedCurrentTime: Double
|
||||
get() = inner.expectedCurrentTime
|
||||
override var speed: Double
|
||||
get() = inner.speed
|
||||
set(_) = Unit
|
||||
override var time: Double
|
||||
get() = inner.time
|
||||
set(_) = Unit
|
||||
override var duration: Double
|
||||
get() = inner.duration
|
||||
set(_) = Unit
|
||||
override var volume: Double
|
||||
get() = inner.volume
|
||||
set(_) = Unit
|
||||
|
||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
||||
override fun resumePlayback() = inner.resumeVideo()
|
||||
override fun pausePlayback() = inner.pauseVideo()
|
||||
override fun stopPlayback() = inner.stopVideo()
|
||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
||||
override fun connect() = inner.start()
|
||||
override fun disconnect() = inner.stop()
|
||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
||||
|
||||
override fun ensureThreadStarted() = when (inner) {
|
||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
class ChromecastCastingDevice : CastingDevice {
|
||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||
@@ -62,7 +62,6 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
private var _autoLaunchEnabled = true
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -306,7 +305,6 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
@@ -548,7 +546,6 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
@@ -561,6 +558,7 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
playVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,22 +568,21 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_autoLaunchEnabled) {
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
}
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
@@ -602,7 +599,6 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
@@ -640,16 +636,10 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stopCasting();
|
||||
stop();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
@@ -686,10 +676,6 @@ class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
@@ -24,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -32,6 +34,7 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -69,7 +72,7 @@ enum class Opcode(val value: Byte) {
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDeviceLegacy {
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
@@ -345,7 +348,7 @@ class FCastCastingDevice : CastingDeviceLegacy {
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.stripLeadingZero
|
||||
import org.bouncycastle.crypto.digests.SHA512Digest
|
||||
import java.math.BigInteger
|
||||
import java.security.SecureRandom
|
||||
|
||||
class SRPClient(private val N: BigInteger, private val g: BigInteger, private val username: String, private val password: String) {
|
||||
private val digest = SHA512Digest()
|
||||
private val hashLen = digest.digestSize
|
||||
private val PAD_L: Int = (N.bitLength() + 7) / 8
|
||||
|
||||
private var a: BigInteger? = null
|
||||
private var A: BigInteger? = null
|
||||
private var S: BigInteger? = null
|
||||
private var sessionKey: ByteArray? = null
|
||||
private var M: ByteArray? = null
|
||||
private var HAMK: ByteArray? = null
|
||||
private var authenticated: Boolean = false
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun isAuthenticated(): Boolean = authenticated
|
||||
fun getSessionKey(): ByteArray? = sessionKey
|
||||
|
||||
fun srp_user_start_authentication(aOverride: BigInteger? = null): BigInteger {
|
||||
a = aOverride ?: BigInteger(256, random)
|
||||
A = g.modPow(a, N)
|
||||
|
||||
if (A!!.mod(N).signum() == 0) {
|
||||
throw IllegalStateException("Invalid client parameter: A mod N = 0")
|
||||
}
|
||||
|
||||
return A!!
|
||||
}
|
||||
|
||||
fun getS(): ByteArray? = S?.toByteArray()?.stripLeadingZero()
|
||||
fun getA(): ByteArray? = A?.toByteArray()?.stripLeadingZero()
|
||||
|
||||
fun srp_user_process_challenge(saltBytes: ByteArray, BBytes: ByteArray): ByteArray {
|
||||
return srp_user_process_challenge_internal(saltBytes, BBytes).third
|
||||
}
|
||||
|
||||
fun srp_user_process_challenge_internal(saltBytes: ByteArray, BBytes: ByteArray): Triple<BigInteger, BigInteger, ByteArray> {
|
||||
if (A == null || a == null) {
|
||||
throw IllegalStateException("Must call srp_user_start_authentication() first.")
|
||||
}
|
||||
|
||||
val B = BigInteger(1, BBytes)
|
||||
val u = H_nn(A!!, B)
|
||||
if (u.signum() == 0) {
|
||||
throw IllegalStateException("Invalid server parameter: u = 0")
|
||||
}
|
||||
|
||||
val x = calculate_x(BigInteger(1, saltBytes))
|
||||
val k = H_nn(N, g)
|
||||
val v = g.modPow(x, N)
|
||||
if (B.mod(N).signum() == 0) {
|
||||
throw IllegalStateException("Invalid server parameter: B mod N = 0")
|
||||
}
|
||||
|
||||
val kv = k.multiply(v).mod(N)
|
||||
val base = B.subtract(kv).mod(N)
|
||||
val exponent = a!!.add(u.multiply(x))
|
||||
S = base.modPow(exponent, N)
|
||||
|
||||
sessionKey = hashBigInteger(S!!)
|
||||
M = calculate_M(saltBytes, A!!, B, sessionKey!!)
|
||||
return Triple(u, v, M!!.clone())
|
||||
}
|
||||
|
||||
fun srp_user_verify_session(serverHAMK: ByteArray): Boolean {
|
||||
if (M == null || sessionKey == null || A == null) {
|
||||
throw IllegalStateException("Must call srp_user_process_challenge() first.")
|
||||
}
|
||||
|
||||
val hamk = calculate_H_AMK(A!!, M!!, sessionKey!!)
|
||||
HAMK = hamk
|
||||
|
||||
authenticated = HAMK!!.contentEquals(serverHAMK)
|
||||
return authenticated
|
||||
}
|
||||
|
||||
private fun H_padded(vararg inputs: BigInteger): BigInteger {
|
||||
val allBytes = inputs.fold(ByteArray(0)) { acc, big -> acc + padTo(big, PAD_L) }
|
||||
val d = SHA512Digest()
|
||||
d.update(allBytes, 0, allBytes.size)
|
||||
val out = ByteArray(hashLen)
|
||||
d.doFinal(out, 0)
|
||||
return BigInteger(1, out)
|
||||
}
|
||||
|
||||
private fun H_nn(bn1: BigInteger, bn2: BigInteger): BigInteger {
|
||||
return H_padded(bn1, bn2)
|
||||
}
|
||||
|
||||
private fun H_ns(n: BigInteger, saltBytes: ByteArray): BigInteger {
|
||||
val nMinimal = n.toByteArray().stripLeadingZero()
|
||||
val concatenated = nMinimal + saltBytes
|
||||
val digest = SHA512Digest()
|
||||
digest.update(concatenated, 0, concatenated.size)
|
||||
val out = ByteArray(hashLen)
|
||||
digest.doFinal(out, 0)
|
||||
return BigInteger(1, out)
|
||||
}
|
||||
|
||||
private fun calculate_x(salt: BigInteger): BigInteger {
|
||||
val userColonPass = username.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x3A /* : */) + password.toByteArray(Charsets.US_ASCII)
|
||||
val ucpHash = hash(userColonPass)
|
||||
return H_ns(salt, ucpHash)
|
||||
}
|
||||
|
||||
private fun hashBigInteger(value: BigInteger): ByteArray {
|
||||
val raw = value.toByteArray().stripLeadingZero()
|
||||
return hash(raw)
|
||||
}
|
||||
|
||||
private fun hash(data: ByteArray): ByteArray {
|
||||
val d = SHA512Digest()
|
||||
d.update(data, 0, data.size)
|
||||
val out = ByteArray(hashLen)
|
||||
d.doFinal(out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun calculate_M(saltBytes: ByteArray, Aint: BigInteger, Bint: BigInteger, K: ByteArray): ByteArray {
|
||||
val H_N = hashBigInteger(N)
|
||||
val H_g = hashBigInteger(g)
|
||||
val H_xor = ByteArray(hashLen) { i -> (H_N[i].toInt() xor H_g[i].toInt()).toByte() }
|
||||
val H_I = hash(username.toByteArray(Charsets.UTF_8))
|
||||
val Abytes = Aint.toByteArray().stripLeadingZero()
|
||||
val Bbytes = Bint.toByteArray().stripLeadingZero()
|
||||
val mDigest = SHA512Digest()
|
||||
mDigest.update(H_xor, 0, hashLen)
|
||||
mDigest.update(H_I, 0, hashLen)
|
||||
mDigest.update(saltBytes, 0, saltBytes.size)
|
||||
mDigest.update(Abytes, 0, Abytes.size)
|
||||
mDigest.update(Bbytes, 0, Bbytes.size)
|
||||
mDigest.update(K, 0, hashLen)
|
||||
val mOut = ByteArray(hashLen)
|
||||
mDigest.doFinal(mOut, 0)
|
||||
return mOut
|
||||
}
|
||||
|
||||
private fun calculate_H_AMK(Aint: BigInteger, M: ByteArray, K: ByteArray): ByteArray {
|
||||
val Abytes = Aint.toByteArray().stripLeadingZero()
|
||||
val hamkDigest = SHA512Digest()
|
||||
hamkDigest.update(Abytes, 0, Abytes.size)
|
||||
hamkDigest.update(M, 0, hashLen)
|
||||
hamkDigest.update(K, 0, hashLen)
|
||||
val out = ByteArray(hashLen)
|
||||
hamkDigest.doFinal(out, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun padTo(value: BigInteger, length: Int): ByteArray {
|
||||
val minimal = value.toByteArray().stripLeadingZero()
|
||||
return if (minimal.size == length) {
|
||||
minimal
|
||||
} else if (minimal.size < length) {
|
||||
ByteArray(length - minimal.size) { 0 } + minimal
|
||||
} else {
|
||||
minimal.copyOfRange(minimal.size - length, minimal.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.CastContext
|
||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||
|
||||
class StateCastingExp : StateCasting() {
|
||||
private val _context = CastContext()
|
||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||
|
||||
class DiscoveryEventHandler(
|
||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||
private val onDeviceRemoved: (String) -> Unit,
|
||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceAdded(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceUpdated(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceRemoved(deviceName: String) {
|
||||
onDeviceRemoved(deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
try {
|
||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||
connectDevice(CastingDeviceExp(foundDevice))
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle URL: $e")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||
try {
|
||||
ad.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return
|
||||
_started = true
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null
|
||||
|
||||
Logger.i(TAG, "CastingService starting...")
|
||||
|
||||
_castServer.start()
|
||||
enableDeveloper(true)
|
||||
|
||||
Logger.i(TAG, "CastingService started.")
|
||||
|
||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||
context,
|
||||
DiscoveryEventHandler(
|
||||
{ deviceInfo -> // Added
|
||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||
val deviceHandle = CastingDeviceExp(device)
|
||||
devices[deviceHandle.device.name()] = deviceHandle
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceAdded.emit(deviceHandle)
|
||||
}
|
||||
},
|
||||
{ deviceName -> // Removed
|
||||
invokeInMainScopeIfRequired {
|
||||
if (devices.containsKey(deviceName)) {
|
||||
val device = devices.remove(deviceName)
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deviceInfo -> // Updated
|
||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||
val handle = devices[deviceInfo.name]
|
||||
if (handle != null && handle is CastingDeviceExp) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started) {
|
||||
return
|
||||
}
|
||||
|
||||
_started = false
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
_scopeIO.cancel()
|
||||
_scopeMain.cancel()
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice
|
||||
activeDevice = null
|
||||
try {
|
||||
d?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||
}
|
||||
|
||||
_castServer.stop()
|
||||
_castServer.removeAllHandlers()
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_deviceDiscoverer = null
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
|
||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingExp"
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetAddress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class StateCastingLegacy : StateCasting() {
|
||||
private var _nsdManager: NsdManager? = null
|
||||
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "fcast") {
|
||||
throw Exception("Expected scheme to be FCast")
|
||||
}
|
||||
|
||||
val type = uri.host
|
||||
if (type != "r") {
|
||||
throw Exception("Expected type r")
|
||||
}
|
||||
|
||||
val connectionInfo = uri.pathSegments[0]
|
||||
val json =
|
||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
.toString(Charsets.UTF_8)
|
||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||
|
||||
val foundInfo = addRememberedDevice(
|
||||
CastingDeviceInfo(
|
||||
name = networkConfig.name,
|
||||
type = CastProtocolType.FCAST,
|
||||
addresses = networkConfig.addresses.toTypedArray(),
|
||||
port = tcpService.port
|
||||
)
|
||||
)
|
||||
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return;
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||
ad.disconnect();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null;
|
||||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
startDiscovering()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
stopDiscovering()
|
||||
_scopeIO.cancel();
|
||||
_scopeMain.cancel();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice;
|
||||
activeDevice = null;
|
||||
d?.disconnect();
|
||||
|
||||
_castServer.stop();
|
||||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(
|
||||
service,
|
||||
{ it.run() },
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
serviceInfo.hostAddresses.toTypedArray(),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
arrayOf(serviceInfo.host),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? {
|
||||
val d = activeDevice;
|
||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
||||
return _scopeMain.launch {
|
||||
while (true) {
|
||||
val device = instance.activeDevice
|
||||
if (device == null || !device.isPlaying) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
||||
setTime(time_ms)
|
||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||
return CastingDeviceLegacyWrapper(
|
||||
when (deviceInfo.type) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
ChromecastCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
AirPlayCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.FCAST -> {
|
||||
FCastCastingDevice(deviceInfo);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun addOrUpdateChromeCastDevice(
|
||||
name: String,
|
||||
addresses: Array<InetAddress>,
|
||||
port: Int
|
||||
) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
ChromecastCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.addresses = addresses;
|
||||
d.inner.port = port;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
AirPlayCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private inline fun addOrUpdateCastDevice(
|
||||
name: String,
|
||||
deviceFactory: () -> CastingDevice,
|
||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
||||
) {
|
||||
var invokeEvents: (() -> Unit)? = null;
|
||||
|
||||
synchronized(devices) {
|
||||
val device = devices[name];
|
||||
if (device != null) {
|
||||
val changed = deviceUpdater(device);
|
||||
if (changed) {
|
||||
invokeEvents = {
|
||||
onDeviceChanged.emit(device);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val newDevice = deviceFactory();
|
||||
this.devices[name] = newDevice
|
||||
|
||||
invokeEvents = {
|
||||
onDeviceAdded.emit(newDevice);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FCastNetworkConfig(
|
||||
val name: String,
|
||||
val addresses: List<String>,
|
||||
val services: List<FCastService>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FCastService(
|
||||
val port: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingLegacy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
enum class TLV8Tag(val value: UByte) {
|
||||
METHOD(0u),
|
||||
IDENTIFIER(1u),
|
||||
SALT(2u),
|
||||
PUBLIC_KEY(3u),
|
||||
PROOF(4u),
|
||||
ENCRYPTED_DATA(5u),
|
||||
STATE(6u),
|
||||
ERROR(7u),
|
||||
RETRY_DELAY(8u),
|
||||
CERTIFICATE(9u),
|
||||
SIGNATURE(0x0Au),
|
||||
PERMISSIONS(0x0Bu),
|
||||
FRAGMENT_DATA(0x0Cu),
|
||||
FRAGMENT_LAST(0x0Du),
|
||||
FLAGS(0x13u),
|
||||
SEPARATOR(0xFFu)
|
||||
}
|
||||
|
||||
data class TLV8Item(val tag: TLV8Tag, val value: UByteArray) {
|
||||
override fun toString(): String {
|
||||
val tagHex = "%02X".format(tag.value.toInt())
|
||||
val dataHex = value.joinToString(" ") { "%02X".format(it.toInt()) }
|
||||
return "${tag.name}(0x$tagHex): $dataHex"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPlayTLV8"
|
||||
private const val FRAGMENT_THRESHOLD = 0xFF
|
||||
|
||||
fun decodeAndReassembleWithLogging(data: UByteArray): Map<TLV8Tag, ByteArray> {
|
||||
val items = decode(data)
|
||||
Logger.i(TAG, "Raw TLV8 items:\n" + items.joinToString("\n") { it.toString() })
|
||||
|
||||
val fields = items
|
||||
.groupBy { it.tag }
|
||||
.mapValues { (_, chunk) ->
|
||||
chunk.fold(UByteArray(0)) { acc, item -> acc + item.value }.toByteArray()
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Reassembled TLV8 fields:\n" +
|
||||
fields.entries.joinToString("\n") { (tag, bytes) ->
|
||||
"%-12s: %s".format(tag.name,
|
||||
bytes.joinToString(" ") { "%02X".format(it) })
|
||||
}
|
||||
)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
fun decodeAsString(data: UByteArray): String {
|
||||
return decode(data).joinToString("\n") { it.toString() }
|
||||
}
|
||||
|
||||
fun itemsToString(items: List<TLV8Item>): String = items.joinToString(separator = "\n") { it.toString() }
|
||||
|
||||
fun encodeWithLogging(items: List<TLV8Item>, useFragmentData: Boolean = false): ByteArray {
|
||||
Logger.i(TAG, "Assembled TLV8 items:\n" + itemsToString(items))
|
||||
|
||||
val fragments = if (useFragmentData) fragmentStandard(items) else fragmentRepeat(items)
|
||||
Logger.i(TAG, "Split TLV8 items:\n" + itemsToString(fragments))
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
fragments.forEach { frag ->
|
||||
val data = frag.value.asByteArray()
|
||||
out.write(frag.tag.value.toInt())
|
||||
out.write(data.size)
|
||||
out.write(data)
|
||||
}
|
||||
val encoded = out.toByteArray()
|
||||
val hexStream = encoded.joinToString(" ") { "%02X".format(it) }
|
||||
Logger.i(TAG, "Final TLV8 byte stream (${encoded.size} bytes):\n$hexStream")
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
private fun fragmentStandard(items: List<TLV8Item>): List<TLV8Item> {
|
||||
val frags = mutableListOf<TLV8Item>()
|
||||
items.forEach { item ->
|
||||
val bytes = item.value.asByteArray()
|
||||
if (bytes.size <= FRAGMENT_THRESHOLD) {
|
||||
frags += item
|
||||
} else {
|
||||
var offset = 0
|
||||
// first fragment with original tag
|
||||
frags += TLV8Item(item.tag, bytes.copyOfRange(0, FRAGMENT_THRESHOLD).toUByteArray())
|
||||
offset += FRAGMENT_THRESHOLD
|
||||
|
||||
// middle fragments
|
||||
while (bytes.size - offset > FRAGMENT_THRESHOLD) {
|
||||
frags += TLV8Item(
|
||||
TLV8Tag.FRAGMENT_DATA,
|
||||
bytes.copyOfRange(offset, offset + FRAGMENT_THRESHOLD).toUByteArray()
|
||||
)
|
||||
offset += FRAGMENT_THRESHOLD
|
||||
}
|
||||
|
||||
// last fragment
|
||||
val rem = bytes.size - offset
|
||||
frags += TLV8Item(
|
||||
TLV8Tag.FRAGMENT_LAST,
|
||||
bytes.copyOfRange(offset, offset + rem).toUByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
return frags
|
||||
}
|
||||
|
||||
private fun fragmentRepeat(items: List<TLV8Item>): List<TLV8Item> {
|
||||
val frags = mutableListOf<TLV8Item>()
|
||||
items.forEach { item ->
|
||||
val bytes = item.value.asByteArray()
|
||||
var offset = 0
|
||||
while (offset < bytes.size) {
|
||||
val chunk = minOf(FRAGMENT_THRESHOLD, bytes.size - offset)
|
||||
frags += TLV8Item(
|
||||
item.tag,
|
||||
bytes.copyOfRange(offset, offset + chunk).toUByteArray()
|
||||
)
|
||||
offset += chunk
|
||||
}
|
||||
}
|
||||
return frags
|
||||
}
|
||||
|
||||
fun decode(data: UByteArray): List<TLV8Item> {
|
||||
val items = mutableListOf<TLV8Item>()
|
||||
var i = 0
|
||||
|
||||
while (i < data.size) {
|
||||
val tagByte = data[i]
|
||||
val tag = TLV8Tag.values().find { it.value == tagByte }
|
||||
?: throw IllegalArgumentException("Unknown tag 0x${tagByte.toString(16)} at offset $i")
|
||||
if (i + 1 >= data.size) {
|
||||
throw IllegalArgumentException("Truncated TLV: no length byte for tag $tag at offset $i")
|
||||
}
|
||||
|
||||
val length = data[i + 1].toInt() and 0xFF
|
||||
i += 2
|
||||
if (i + length > data.size) {
|
||||
throw IllegalArgumentException("Truncated TLV: declared length $length exceeds available bytes (${data.size - i})")
|
||||
}
|
||||
|
||||
var value = data.copyOfRange(i, i + length)
|
||||
i += length
|
||||
|
||||
if (length == FRAGMENT_THRESHOLD && i < data.size) {
|
||||
val nextTag = data[i]
|
||||
if (nextTag == TLV8Tag.FRAGMENT_DATA.value ||
|
||||
nextTag == TLV8Tag.FRAGMENT_LAST.value
|
||||
) {
|
||||
while (true) {
|
||||
if (i + 2 > data.size) {
|
||||
throw IllegalArgumentException("Truncated fragment header at offset $i")
|
||||
}
|
||||
val fragTagByte = data[i]
|
||||
val fragTag = TLV8Tag.values().find { it.value == fragTagByte }
|
||||
?: throw IllegalArgumentException("Unknown fragment tag 0x${fragTagByte.toString(16)} at offset $i")
|
||||
val fragLen = data[i + 1].toInt() and 0xFF
|
||||
i += 2
|
||||
if (i + fragLen > data.size) {
|
||||
throw IllegalArgumentException("Truncated fragment: declared length $fragLen exceeds available bytes (${data.size - i})")
|
||||
}
|
||||
val fragData = data.copyOfRange(i, i + fragLen)
|
||||
value += fragData
|
||||
i += fragLen
|
||||
|
||||
if (fragTag == TLV8Tag.FRAGMENT_LAST) break
|
||||
if (fragTag != TLV8Tag.FRAGMENT_DATA) {
|
||||
throw IllegalArgumentException("Unexpected tag $fragTag in fragment sequence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items += TLV8Item(tag, value)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
|
||||
val remotes = mutableListOf<V8RemoteObject?>();
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
|
||||
val remotes = mutableListOf<V8RemoteObject>();
|
||||
for(obj in objs)
|
||||
remotes.add(createRemoteObject(obj));
|
||||
remotes.add(createRemoteObject(obj)!!);
|
||||
return remotes;
|
||||
}
|
||||
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
||||
|
||||
@@ -8,13 +8,11 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
|
||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
@@ -40,13 +38,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
||||
R.array.exp_casting_device_type_array
|
||||
} else {
|
||||
R.array.casting_device_type_array
|
||||
}
|
||||
|
||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
_spinnerType.adapter = adapter;
|
||||
};
|
||||
@@ -109,16 +101,12 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_textError.visibility = View.GONE;
|
||||
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
|
||||
try {
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to add remembered device: $e")
|
||||
}
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
||||
performDismiss();
|
||||
};
|
||||
|
||||
_buttonTutorial.setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
|
||||
UIDialogs.showCastingTutorialDialog(context)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -142,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
||||
if (shouldShowCastingDialog) {
|
||||
UIDialogs.showCastingDialog(context, ownerActivity);
|
||||
UIDialogs.showCastingDialog(context);
|
||||
}
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
dismiss()
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity)
|
||||
UIDialogs.showCastingAddDialog(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
@@ -17,6 +18,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -81,7 +83,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity);
|
||||
UIDialogs.showCastingAddDialog(context);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -106,20 +108,19 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
val name = d.name
|
||||
if (name != null) {
|
||||
if (name != null)
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name && it.castingDevice.protocol == d.protocol }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
@@ -138,6 +139,9 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
@@ -157,20 +161,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
return oldList[oldItemPosition].castingDevice.name == newList[newItemPosition].castingDevice.name && oldList[oldItemPosition].castingDevice.protocol == newList[newItemPosition].castingDevice.protocol
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
|
||||
return oldItem == newItem
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.AirPlay1CastingDevice
|
||||
import com.futo.platformplayer.casting.AirPlay2CastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
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.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -68,18 +70,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonPlay = findViewById(R.id.button_play);
|
||||
_buttonPlay.setOnClickListener {
|
||||
StateCasting.instance.resumeVideo()
|
||||
StateCasting.instance.activeDevice?.resumeVideo()
|
||||
}
|
||||
|
||||
_buttonPause = findViewById(R.id.button_pause);
|
||||
_buttonPause.setOnClickListener {
|
||||
StateCasting.instance.pauseVideo()
|
||||
StateCasting.instance.activeDevice?.pauseVideo()
|
||||
}
|
||||
|
||||
_buttonStop = findViewById(R.id.button_stop);
|
||||
_buttonStop.setOnClickListener {
|
||||
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||
StateCasting.instance.stopVideo()
|
||||
StateCasting.instance.activeDevice?.stopVideo()
|
||||
}
|
||||
|
||||
_buttonNext = findViewById(R.id.button_next);
|
||||
@@ -89,11 +91,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonDisconnect.setOnClickListener {
|
||||
try {
|
||||
StateCasting.instance.activeDevice?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Active device failed to disconnect: $e")
|
||||
}
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -102,7 +100,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
StateCasting.instance.videoSeekTo(value.toDouble())
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
try {
|
||||
activeDevice.seekVideo(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
});
|
||||
|
||||
//TODO: Check if volume slider is properly hidden in all cases
|
||||
@@ -111,7 +114,14 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
StateCasting.instance.changeVolume(value.toDouble())
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
if (activeDevice.canSetVolume) {
|
||||
try {
|
||||
activeDevice.changeVolume(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
@@ -163,25 +173,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private fun updateDevice() {
|
||||
val d = StateCasting.instance.activeDevice ?: return;
|
||||
|
||||
when (d.protocolType) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
}
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlay1CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is AirPlay2CastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.airplay_audio_logo);
|
||||
_textType.text = "AirPlay 2";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FastCast";
|
||||
}
|
||||
|
||||
_textName.text = d.name;
|
||||
@@ -193,7 +196,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
||||
_sliderPosition.valueTo = dur
|
||||
|
||||
if (d.canSetVolume()) {
|
||||
if (d.canSetVolume) {
|
||||
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
||||
_layoutVolumeFixed.visibility = View.GONE;
|
||||
} else {
|
||||
@@ -215,7 +218,8 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
CastConnectionState.CONNECTED -> {
|
||||
enableControls(interactiveControls)
|
||||
}
|
||||
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
|
||||
CastConnectionState.CONNECTING,
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
disableControls(interactiveControls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ 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;
|
||||
@@ -45,17 +41,8 @@ class ImportOptionsDialog: AlertDialog {
|
||||
_button_import_zip.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/zip") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes);
|
||||
};
|
||||
}
|
||||
_button_import_ezip.setOnClickListener {
|
||||
@@ -64,35 +51,17 @@ class ImportOptionsDialog: AlertDialog {
|
||||
_button_import_txt.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "text/plain") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val txt = String(txtBytes);
|
||||
StateBackup.importTxt(_context, txt);
|
||||
};
|
||||
}
|
||||
_button_import_newpipe_subs.onClick.subscribe {
|
||||
dismiss();
|
||||
StateApp.instance.requestFileReadAccess(_context, null, "application/json") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
|
||||
val json = String(jsonBytes);
|
||||
StateBackup.importNewPipeSubs(_context, json);
|
||||
};
|
||||
};
|
||||
_button_import_platform.onClick.subscribe {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
||||
class PairingCodeDialog(context: Context?, private val onSubmit: (code: String) -> Unit) : AlertDialog(context) {
|
||||
private lateinit var _editPairingCode: EditText
|
||||
private lateinit var _textError: TextView
|
||||
private lateinit var _buttonSubmit: LinearLayout
|
||||
private lateinit var _buttonCancel: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_pairing_code, null))
|
||||
|
||||
_editPairingCode = findViewById(R.id.edit_pairing_code)
|
||||
_textError = findViewById(R.id.text_error)
|
||||
_buttonSubmit = findViewById(R.id.button_submit)
|
||||
_buttonCancel = findViewById(R.id.button_cancel)
|
||||
|
||||
setTitle("Enter Pairing Code")
|
||||
|
||||
_buttonCancel.setOnClickListener {
|
||||
performDismiss()
|
||||
}
|
||||
|
||||
_buttonSubmit.setOnClickListener {
|
||||
val code = _editPairingCode.text.toString().trim()
|
||||
if (code.isBlank()) {
|
||||
_textError.text = "Pairing code cannot be empty."
|
||||
_textError.visibility = View.VISIBLE
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
_textError.visibility = View.GONE
|
||||
onSubmit(code)
|
||||
performDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
super.show()
|
||||
|
||||
_editPairingCode.text.clear()
|
||||
_textError.visibility = View.GONE
|
||||
|
||||
window?.apply {
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDismiss() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "PairingCodeDialog"
|
||||
}
|
||||
}
|
||||
@@ -303,10 +303,9 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -352,10 +351,9 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -719,7 +717,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written: Long = 0;
|
||||
var written = 0;
|
||||
var indexCounter = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
for(cue in foundCues) {
|
||||
@@ -744,7 +742,7 @@ class VideoDownload {
|
||||
|
||||
indexCounter++;
|
||||
}
|
||||
sourceLength = written;
|
||||
sourceLength = written.toLong();
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
||||
|
||||
override val isShort: Boolean get() = videoSerialized.isShort;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
//TODO: Offline subtitles
|
||||
override val subtitles: List<ISubtitleSource> = listOf();
|
||||
|
||||
|
||||
@@ -10,15 +10,12 @@ 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
|
||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCompilationException
|
||||
@@ -37,19 +34,10 @@ import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.engine.packages.PackageJSDOM
|
||||
import com.futo.platformplayer.engine.packages.PackageUtilities
|
||||
import com.futo.platformplayer.engine.packages.V8Package
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
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
|
||||
@@ -60,7 +48,6 @@ 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;
|
||||
@@ -236,146 +223,37 @@ 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();
|
||||
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
try {
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
_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);
|
||||
|
||||
val result = busy {
|
||||
return busy {
|
||||
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
return@busy catchScriptErrors<V8Value>("Plugin[${config.name}]", js) {
|
||||
return@busy catchScriptErrors("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) {
|
||||
@@ -515,29 +393,18 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
|
||||
throw getExceptionFromPlugin(config, pluginType, msg, innerEx, stack, code);
|
||||
}
|
||||
fun getExceptionFromPlugin(config: IV8PluginConfig, obj: V8ValueObject, innerEx: Exception? = null, stack: String? = null, code: String? = null, prefix: String? = null): PluginException {
|
||||
val pluginType = obj.getOrDefault(config, "plugin_type", "Exception Handling", "")?.let { if(!it.isNullOrBlank()) it + "" else "" } ?: "";
|
||||
var msg = obj.getOrDefault<String?>(config, "msg", "Exception Handling", null)
|
||||
?: obj.getOrDefault(config, "message", "Exception Handling", "");
|
||||
if(!prefix.isNullOrBlank())
|
||||
msg = prefix + msg;
|
||||
return getExceptionFromPlugin(config, pluginType, msg ?: "Unknown exception", innerEx, stack, code);
|
||||
}
|
||||
fun getExceptionFromPlugin(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null): PluginException {
|
||||
when(pluginType) {
|
||||
"ScriptException" -> return ScriptException(config, msg, innerEx, stack, code);
|
||||
"CriticalException" -> return ScriptCriticalException(config, msg, innerEx, stack, code);
|
||||
"AgeException" -> return ScriptAgeException(config, msg, innerEx, stack, code);
|
||||
"UnavailableException" -> return ScriptUnavailableException(config, msg, innerEx, stack, code);
|
||||
"ScriptLoginRequiredException" -> return ScriptLoginRequiredException(config, msg, innerEx, stack, code);
|
||||
"ScriptExecutionException" -> return ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptCompilationException" -> return ScriptCompilationException(config, msg, innerEx, code);
|
||||
"ScriptImplementationException" -> return ScriptImplementationException(config, msg, innerEx, null, code);
|
||||
"ScriptTimeoutException" -> return ScriptTimeoutException(config, msg, innerEx);
|
||||
"NoInternetException" -> return NoInternetException(config, msg, innerEx, stack, code);
|
||||
else -> return ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
|
||||
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
|
||||
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
|
||||
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
|
||||
"ScriptLoginRequiredException" -> throw ScriptLoginRequiredException(config, msg, innerEx, stack, code);
|
||||
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptCompilationException" -> throw ScriptCompilationException(config, msg, innerEx, code);
|
||||
"ScriptImplementationException" -> throw ScriptImplementationException(config, msg, innerEx, null, code);
|
||||
"ScriptTimeoutException" -> throw ScriptTimeoutException(config, msg, innerEx);
|
||||
"NoInternetException" -> throw NoInternetException(config, msg, innerEx, stack, code);
|
||||
else -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class V8RemoteObject {
|
||||
}
|
||||
|
||||
|
||||
fun List<V8RemoteObject?>.serialize() : String {
|
||||
fun List<V8RemoteObject>.serialize() : String {
|
||||
return _gson.toJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,7 @@ class PackageBridge : V8Package {
|
||||
fun supportedFeatures(): Array<String> {
|
||||
return arrayOf(
|
||||
"ReloadRequiredException",
|
||||
"HttpBatchClient",
|
||||
"Async"
|
||||
"HttpBatchClient"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,12 +130,9 @@ 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);
|
||||
@@ -194,11 +190,7 @@ class PackageBridge : V8Package {
|
||||
|
||||
val stackTrace = Thread.currentThread().stackTrace;
|
||||
val callerMethod = stackTrace.findLast {
|
||||
it.className == JSClient::class.java.name &&
|
||||
it.methodName != "isBusy" &&
|
||||
it.methodName != "busy" &&
|
||||
it.methodName != "getCopy" &&
|
||||
it.methodName != "isBusyWith"
|
||||
it.className == JSClient::class.java.name
|
||||
}?.methodName ?: "";
|
||||
val session = StateApp.instance.sessionId;
|
||||
val pluginId = _plugin.config.id;
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -254,7 +253,7 @@ class PackageHttp: V8Package {
|
||||
|
||||
//TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future.
|
||||
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
|
||||
class BatchBuilder(@Transient private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
|
||||
class BatchBuilder(private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
|
||||
@Transient
|
||||
private val _reqs = existingRequests;
|
||||
|
||||
@@ -669,7 +668,7 @@ class PackageHttp: V8Package {
|
||||
if(hasOpen && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeV8Void("open", arrayOf<Any>());
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -681,7 +680,7 @@ class PackageHttp: V8Package {
|
||||
if(hasMessage && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeV8Void("message", msg);
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
@@ -692,7 +691,7 @@ class PackageHttp: V8Package {
|
||||
{
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeV8Void("closing", code, reason);
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -705,7 +704,7 @@ class PackageHttp: V8Package {
|
||||
if(hasClosed && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeV8Void("closed", code, reason);
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
@@ -723,7 +722,7 @@ class PackageHttp: V8Package {
|
||||
if(hasFailure && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_package._plugin.busy {
|
||||
_listeners?.invokeV8Void("failure", exception.message);
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
|
||||
+3
-7
@@ -25,7 +25,6 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
@@ -62,7 +61,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>();
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
val onUrlClicked = Event1<String>();
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
@@ -209,13 +208,10 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
_searchView = searchView
|
||||
updateSearchViewVisibility()
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe { content, num ->
|
||||
val results = ArrayList(_results)
|
||||
this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results))
|
||||
}
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos)
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(
|
||||
lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||
).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
|
||||
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
|
||||
|
||||
+1
-4
@@ -15,7 +15,6 @@ import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -376,7 +375,6 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
fun newInstance() = MenuBottomBarFragment().apply { }
|
||||
|
||||
@UnstableApi
|
||||
//Add configurable buttons here
|
||||
var buttonDefinitions = listOf(
|
||||
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, {
|
||||
@@ -392,14 +390,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate<ShortsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
|
||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
||||
val intent = Intent(c, SettingsActivity::class.java);
|
||||
|
||||
-8
@@ -211,14 +211,6 @@ class ChannelFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onShortClicked.subscribe { v, _, pagerPair ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onAddToClicked.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
|
||||
+3
-15
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
@@ -20,7 +19,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
@@ -36,9 +34,6 @@ import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -64,7 +59,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
|
||||
_exoPlayer = player;
|
||||
|
||||
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
attachAdapterEvents(this);
|
||||
}
|
||||
}
|
||||
@@ -251,15 +246,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
}
|
||||
|
||||
//TODO: Is this still necessary?
|
||||
if(viewHolder.childViewHolder is ContentPreviewViewHolder) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "playPreview failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if(viewHolder.childViewHolder is ContentPreviewViewHolder)
|
||||
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
||||
}
|
||||
|
||||
private fun stopVideo() {
|
||||
|
||||
@@ -279,14 +279,6 @@ class HomeFragment : MainFragment() {
|
||||
else {
|
||||
view.setToggle(!active);
|
||||
}
|
||||
}, { view, views, enabled ->
|
||||
val toDisable = views.filter { it != view && it.tag == "plugins" };
|
||||
if(!view.isActive)
|
||||
view.handleClick();
|
||||
for(tag in toDisable) {
|
||||
if(tag.isActive)
|
||||
tag.handleClick();
|
||||
}
|
||||
}).withTag("plugins")
|
||||
})
|
||||
else listOf())
|
||||
|
||||
@@ -1,883 +0,0 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
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.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.views.buttons.ShortsButton
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
|
||||
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.video.FutoShortPlayer
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
import com.futo.polycentric.core.Opinion
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import com.google.android.material.button.MaterialButton
|
||||
//import com.google.android.material.button.MaterialButton
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import userpackage.Protocol
|
||||
|
||||
@UnstableApi
|
||||
class ShortView : FrameLayout {
|
||||
private lateinit var fragment: MainFragment
|
||||
private val player: FutoShortPlayer
|
||||
|
||||
private val channelInfo: LinearLayout
|
||||
private val creatorThumbnail: CreatorThumbnail
|
||||
private val channelName: TextView
|
||||
private val videoTitle: TextView
|
||||
private val videoSubtitle: TextView
|
||||
private val platformIndicator: PlatformIndicator
|
||||
|
||||
//TODO: Replace with non-material button
|
||||
private val backButton: MaterialButton
|
||||
private val backButtonContainer: ConstraintLayout
|
||||
|
||||
private val likeButton: ShortsButton
|
||||
//private val likeCount: TextView
|
||||
private val dislikeButton: ShortsButton
|
||||
//private val dislikeCount: TextView
|
||||
|
||||
private val commentsButton: ShortsButton
|
||||
private val shareButton: ShortsButton
|
||||
private val refreshButton: ShortsButton
|
||||
private val qualityButton: ShortsButton
|
||||
|
||||
private val playPauseOverlay: FrameLayout
|
||||
private val playPauseIcon: ImageView
|
||||
|
||||
private val overlayLoading: FrameLayout
|
||||
private val overlayLoadingSpinner: ImageView
|
||||
private lateinit var overlayQualityContainer: FrameLayout
|
||||
|
||||
private var overlayQualitySelector: SlideUpMenuOverlay? = null
|
||||
|
||||
private var video: IPlatformVideo? = null
|
||||
set(value) {
|
||||
field = value
|
||||
onVideoUpdated.emit(value)
|
||||
}
|
||||
private var videoDetails: IPlatformVideoDetails? = null
|
||||
|
||||
private var playWhenReady = false
|
||||
|
||||
private var _lastVideoSource: IVideoSource? = null
|
||||
private var _lastAudioSource: IAudioSource? = null
|
||||
private var _lastSubtitleSource: ISubtitleSource? = null
|
||||
|
||||
private var loadVideoTask: TaskHandler<String, IPlatformVideoDetails>? = null
|
||||
private var loadLikesTask: TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>? =
|
||||
null
|
||||
|
||||
val onResetTriggered = Event0()
|
||||
private val onPlayingToggled = Event1<Boolean>()
|
||||
private val onLikesLoaded = Event3<RatingLikeDislikes, Boolean, Boolean>()
|
||||
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
|
||||
private val onVideoUpdated = Event1<IPlatformVideo?>()
|
||||
|
||||
//TODO: Replace with non-material UI? Only true dependency on Material left
|
||||
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
|
||||
|
||||
var likes: Long = 0
|
||||
set(value) {
|
||||
field = value
|
||||
likeButton.withPrimaryText(value.toString());
|
||||
//likeCount.text = value.toString()
|
||||
}
|
||||
|
||||
var dislikes: Long = 0
|
||||
set(value) {
|
||||
field = value
|
||||
dislikeButton.withPrimaryText(value.toString());
|
||||
//dislikeCount.text = value.toString()
|
||||
}
|
||||
|
||||
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
|
||||
this.overlayQualityContainer = overlayQualityContainer
|
||||
|
||||
layoutParams = LayoutParams(
|
||||
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
this.fragment = fragment
|
||||
bottomSheet.mainFragment = fragment
|
||||
}
|
||||
|
||||
// Required constructor for XML inflation
|
||||
constructor(context: Context) : this(context, null, null)
|
||||
|
||||
// Required constructor for XML inflation with attributes
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null)
|
||||
|
||||
// Required constructor for XML inflation with attributes and style
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super(
|
||||
context, attrs, defStyleAttr ?: 0
|
||||
) {
|
||||
// Inflate the layout once here
|
||||
inflate(context, R.layout.view_short, this)
|
||||
|
||||
// Initialize all val properties using findViewById
|
||||
player = findViewById(R.id.short_player)
|
||||
channelInfo = findViewById(R.id.channel_info)
|
||||
creatorThumbnail = findViewById(R.id.creator_thumbnail)
|
||||
channelName = findViewById(R.id.channel_name)
|
||||
videoTitle = findViewById(R.id.video_title)
|
||||
videoSubtitle = findViewById(R.id.video_subtitle)
|
||||
platformIndicator = findViewById(R.id.short_platform_indicator)
|
||||
backButton = findViewById(R.id.back_button)
|
||||
backButtonContainer = findViewById(R.id.back_button_container)
|
||||
likeButton = findViewById(R.id.like_button)
|
||||
//likeCount = findViewById(R.id.like_count)
|
||||
dislikeButton = findViewById(R.id.dislike_button)
|
||||
//dislikeCount = findViewById(R.id.dislike_count)
|
||||
commentsButton = findViewById(R.id.comments_button)
|
||||
shareButton = findViewById(R.id.share_button)
|
||||
refreshButton = findViewById(R.id.refresh_button)
|
||||
qualityButton = findViewById(R.id.quality_button)
|
||||
playPauseOverlay = findViewById(R.id.play_pause_overlay)
|
||||
playPauseIcon = findViewById(R.id.play_pause_icon)
|
||||
overlayLoading = findViewById(R.id.short_view_loading_overlay)
|
||||
overlayLoadingSpinner = findViewById(R.id.short_view_loader)
|
||||
|
||||
player.setOnClickListener {
|
||||
if (player.activelyPlaying) {
|
||||
player.pause()
|
||||
onPlayingToggled.emit(false)
|
||||
} else {
|
||||
player.play()
|
||||
onPlayingToggled.emit(true)
|
||||
}
|
||||
}
|
||||
|
||||
onPlayingToggled.subscribe { playing ->
|
||||
if (playing) {
|
||||
playPauseIcon.setImageResource(R.drawable.ic_play)
|
||||
playPauseIcon.contentDescription = context.getString(R.string.play)
|
||||
} else {
|
||||
playPauseIcon.setImageResource(R.drawable.ic_pause)
|
||||
playPauseIcon.contentDescription = context.getString(R.string.pause)
|
||||
}
|
||||
showPlayPauseIcon()
|
||||
}
|
||||
|
||||
onVideoUpdated.subscribe {
|
||||
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
|
||||
videoTitle.text = it?.name
|
||||
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
|
||||
platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
|
||||
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
|
||||
channelName.text = it?.author?.name
|
||||
}
|
||||
|
||||
backButton.setOnClickListener {
|
||||
fragment.closeSegment()
|
||||
}
|
||||
|
||||
channelInfo.setOnClickListener {
|
||||
fragment.navigate<ChannelFragment>(video?.author)
|
||||
}
|
||||
|
||||
videoTitle.setOnClickListener {
|
||||
if (!bottomSheet.isAdded) {
|
||||
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
commentsButton.onClick.subscribe {
|
||||
if (!bottomSheet.isAdded) {
|
||||
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
shareButton.onClick.subscribe {
|
||||
val url = video?.shareUrl ?: video?.url
|
||||
fragment.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, url)
|
||||
type = "text/plain"
|
||||
}, null))
|
||||
}
|
||||
|
||||
refreshButton.onClick.subscribe {
|
||||
onResetTriggered.emit()
|
||||
}
|
||||
|
||||
refreshButton.setOnLongClickListener {
|
||||
UIDialogs.toast(context, "Reload all platform shorts pagers")
|
||||
false
|
||||
}
|
||||
|
||||
qualityButton.onClick.subscribe {
|
||||
showVideoSettings()
|
||||
}
|
||||
|
||||
likeButton.onClick.subscribe {
|
||||
val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
|
||||
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||
if (checked) {
|
||||
likes++
|
||||
} else {
|
||||
likes--
|
||||
}
|
||||
|
||||
if(checked)
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
|
||||
else
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s)
|
||||
|
||||
if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
|
||||
//dislikeButton.isChecked = false
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||
dislikes--
|
||||
}
|
||||
|
||||
onLikeDislikeUpdated.emit(
|
||||
OnLikeDislikeUpdatedArgs(
|
||||
it, likes, checked, dislikes, !checked
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dislikeButton.onClick.subscribe {
|
||||
val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
|
||||
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||
if (checked) {
|
||||
dislikes++
|
||||
} else {
|
||||
dislikes--
|
||||
}
|
||||
|
||||
//dislikeButton.isChecked = checked
|
||||
if(checked)
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked
|
||||
else
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||
|
||||
if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
|
||||
//likeButton.isChecked = false
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s);
|
||||
likes--
|
||||
}
|
||||
|
||||
onLikeDislikeUpdated.emit(
|
||||
OnLikeDislikeUpdatedArgs(
|
||||
it, likes, !checked, dislikes, checked
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
|
||||
likes = rating.likes
|
||||
dislikes = rating.dislikes
|
||||
//likeButton.isChecked = liked
|
||||
//dislikeButton.isChecked = disliked
|
||||
|
||||
dislikeButton.visibility = VISIBLE
|
||||
likeButton.visibility = VISIBLE
|
||||
}
|
||||
|
||||
player.onPlaybackStateChanged.subscribe {
|
||||
val videoSource = _lastVideoSource
|
||||
|
||||
if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||
val videoTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
val audioTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO }
|
||||
|
||||
val videoTrackFormats = mutableListOf<Format>()
|
||||
val audioTrackFormats = mutableListOf<Format>()
|
||||
|
||||
if (videoTracks != null) {
|
||||
for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i))
|
||||
}
|
||||
if (audioTracks != null) {
|
||||
for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i))
|
||||
}
|
||||
|
||||
updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height }
|
||||
.sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate }
|
||||
.sortedBy { it.bitrate })
|
||||
} else {
|
||||
updateQualitySourcesOverlay(videoDetails, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPlayPauseIcon() {
|
||||
val overlay = playPauseOverlay
|
||||
|
||||
overlay.alpha = 0f
|
||||
overlay.scaleX = 0f
|
||||
overlay.scaleY = 0f
|
||||
overlay.visibility = VISIBLE
|
||||
|
||||
overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400)
|
||||
.setInterpolator(OvershootInterpolator(1.2f)).start()
|
||||
|
||||
overlay.postDelayed({
|
||||
hidePlayPauseIcon()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
private fun hidePlayPauseIcon() {
|
||||
val overlay = playPauseOverlay
|
||||
|
||||
overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300)
|
||||
.setInterpolator(AccelerateInterpolator()).withEndAction {
|
||||
overlay.visibility = GONE
|
||||
}.start()
|
||||
}
|
||||
|
||||
// TODO merge this with the updateQualitySourcesOverlay for the normal video player
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
Logger.i(TAG, "updateQualitySourcesOverlay")
|
||||
|
||||
val video: IPlatformVideoDetails?
|
||||
val localVideoSources: List<LocalVideoSource>?
|
||||
val localAudioSource: List<LocalAudioSource>?
|
||||
val localSubtitleSources: List<LocalSubtitleSource>?
|
||||
|
||||
val videoSources: List<IVideoSource>?
|
||||
val audioSources: List<IAudioSource>?
|
||||
|
||||
if (videoDetails is VideoLocal) {
|
||||
video = videoLocal?.videoSerialized
|
||||
localVideoSources = videoDetails.videoSource.toList()
|
||||
localAudioSource = videoDetails.audioSource.toList()
|
||||
localSubtitleSources = videoDetails.subtitlesSources.toList()
|
||||
videoSources = null
|
||||
audioSources = null
|
||||
} else {
|
||||
video = videoDetails
|
||||
videoSources = video?.video?.videoSources?.toList()
|
||||
audioSources =
|
||||
if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||
else null
|
||||
if (videoLocal != null) {
|
||||
localVideoSources = videoLocal.videoSource.toList()
|
||||
localAudioSource = videoLocal.audioSource.toList()
|
||||
localSubtitleSources = videoLocal.subtitlesSources.toList()
|
||||
} else {
|
||||
localVideoSources = null
|
||||
localAudioSource = null
|
||||
localSubtitleSources = null
|
||||
}
|
||||
}
|
||||
|
||||
val doDedup = Settings.instance.playback.simplifySources
|
||||
|
||||
val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct()
|
||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct()
|
||||
?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
|
||||
val bestAudioContainer =
|
||||
audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }
|
||||
val bestAudioSources =
|
||||
if (doDedup) audioSources?.filter { it.container == bestAudioContainer }
|
||||
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
|
||||
?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf()
|
||||
|
||||
val canSetSpeed = true
|
||||
val currentPlaybackRate = player.getPlaybackRate()
|
||||
overlayQualitySelector =
|
||||
SlideUpMenuOverlay(
|
||||
this.context, overlayQualityContainer, context.getString(
|
||||
R.string.quality
|
||||
), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString())
|
||||
onClick.subscribe { v ->
|
||||
|
||||
player.setPlaybackRate(v.toFloat())
|
||||
setSelected(v)
|
||||
|
||||
}
|
||||
} else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.stream_video), "video", (listOf(
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) })
|
||||
) + (liveStreamVideoFormats.map {
|
||||
SlideUpMenuItem(
|
||||
this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType
|
||||
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) })
|
||||
}))
|
||||
)
|
||||
else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.video), "video", *bestVideoSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it)
|
||||
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it)
|
||||
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
|
||||
Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)")
|
||||
if (_lastVideoSource == videoSource) return
|
||||
|
||||
_lastVideoSource = videoSource
|
||||
|
||||
playVideo(player.position)
|
||||
}
|
||||
|
||||
private fun handleSelectAudioTrack(audioSource: IAudioSource) {
|
||||
Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)")
|
||||
if (_lastAudioSource == audioSource) return
|
||||
|
||||
_lastAudioSource = audioSource
|
||||
|
||||
playVideo(player.position)
|
||||
}
|
||||
|
||||
private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) {
|
||||
Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)")
|
||||
var toSet: ISubtitleSource? = subtitleSource
|
||||
if (_lastSubtitleSource == subtitleSource) toSet = null
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
player.swapSubtitles(toSet)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastSubtitleSource = toSet
|
||||
}
|
||||
|
||||
private fun showVideoSettings() {
|
||||
Logger.i(TAG, "showVideoSettings")
|
||||
|
||||
overlayQualitySelector?.selectOption("video", _lastVideoSource)
|
||||
overlayQualitySelector?.selectOption("audio", _lastAudioSource)
|
||||
overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource)
|
||||
|
||||
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
|
||||
val videoTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
|
||||
var selectedQuality: Format? = null
|
||||
|
||||
if (videoTracks != null) {
|
||||
for (i in 0 until videoTracks.mediaTrackGroup.length) {
|
||||
if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) {
|
||||
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var videoMenuGroup: SlideUpMenuGroup? = null
|
||||
for (view in overlayQualitySelector!!.groupItems) {
|
||||
if (view is SlideUpMenuGroup && view.groupTag == "video") {
|
||||
videoMenuGroup = view
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedQuality != null) {
|
||||
videoMenuGroup?.getItem("auto")?.setSubText("")
|
||||
overlayQualitySelector?.selectOption("video", selectedQuality)
|
||||
} else {
|
||||
videoMenuGroup?.getItem("auto")
|
||||
?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}")
|
||||
overlayQualitySelector?.selectOption("video", "auto")
|
||||
}
|
||||
}
|
||||
|
||||
val currentPlaybackRate = player.getPlaybackRate()
|
||||
overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }
|
||||
?.let {
|
||||
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
|
||||
}
|
||||
|
||||
overlayQualitySelector?.show()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
|
||||
this.fragment = fragment
|
||||
this.bottomSheet.mainFragment = fragment
|
||||
this.overlayQualityContainer = overlayQualityContainer
|
||||
}
|
||||
|
||||
fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) {
|
||||
if (this.video?.url == video.url) {
|
||||
return
|
||||
}
|
||||
this.video = video
|
||||
|
||||
refreshButton.visibility = if (isChannelShortsMode) {
|
||||
GONE
|
||||
} else {
|
||||
GONE //TODO: Revert?
|
||||
}
|
||||
backButtonContainer.visibility = if (isChannelShortsMode) {
|
||||
VISIBLE
|
||||
} else {
|
||||
GONE
|
||||
}
|
||||
|
||||
loadVideo(video.url)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun changeVideo(videoDetails: IPlatformVideoDetails) {
|
||||
if (video?.url == videoDetails.url) {
|
||||
return
|
||||
}
|
||||
|
||||
this.video = videoDetails
|
||||
this.videoDetails = videoDetails
|
||||
}
|
||||
|
||||
fun play() {
|
||||
loadLikes(this.video!!)
|
||||
player.clear()
|
||||
player.attach()
|
||||
player.clear()
|
||||
playVideo()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playWhenReady = false
|
||||
|
||||
player.clear()
|
||||
player.detach()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
loadVideoTask?.cancel()
|
||||
loadLikesTask?.cancel()
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
overlayLoading.visibility = VISIBLE
|
||||
} else {
|
||||
overlayLoading.visibility = GONE
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLikes(video: IPlatformVideo) {
|
||||
likeButton.visibility = GONE
|
||||
dislikeButton.visibility = GONE
|
||||
|
||||
loadLikesTask?.cancel()
|
||||
loadLikesTask =
|
||||
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
|
||||
StateApp.instance.scopeGetter, {
|
||||
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||
val extraBytesRef =
|
||||
video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null, arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
)
|
||||
.build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
), extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
)
|
||||
|
||||
Pair(ref, queryReferencesResponse)
|
||||
}).success { (ref, queryReferencesResponse) ->
|
||||
val likes = queryReferencesResponse.countsList[0]
|
||||
val dislikes = queryReferencesResponse.countsList[1]
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
|
||||
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
|
||||
onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like)
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike)
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral)
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill")
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions()
|
||||
Logger.i(TAG, "Finished backfill")
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref, args.hasLiked, args.hasDisliked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
loadLikesTask?.run(video)
|
||||
}
|
||||
|
||||
private fun loadVideo(url: String) {
|
||||
loadVideoTask?.cancel()
|
||||
videoDetails = null
|
||||
_lastVideoSource = null
|
||||
_lastAudioSource = null
|
||||
_lastSubtitleSource = null
|
||||
|
||||
setLoading(true)
|
||||
|
||||
Logger.i(TAG, "Shorts loadVideo [${url}]");
|
||||
val timeLoadVideoStart = System.currentTimeMillis();
|
||||
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
|
||||
StateApp.instance.scopeGetter, {
|
||||
val result = StatePlatform.instance.getContentDetails(it).await()
|
||||
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
|
||||
return@TaskHandler result
|
||||
}).success { result ->
|
||||
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
|
||||
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
|
||||
videoDetails = result
|
||||
video = result
|
||||
|
||||
if(Settings.instance.playback.shortsPregenerate)
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if(result != null) {
|
||||
val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
|
||||
val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
|
||||
|
||||
if(prefVid != null && prefVid is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Shorts pregenerating video (${result.name})");
|
||||
prefVid.pregenerateAsync(fragment.lifecycleScope);
|
||||
}
|
||||
if(prefAud != null && prefAud is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Shorts pregenerating audio (${result.name})");
|
||||
prefAud.pregenerateAsync(fragment.lifecycleScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bottomSheet.video = result
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (playWhenReady) playVideo()
|
||||
}.exception<NoPlatformClientException> {
|
||||
Logger.w(TAG, "exception<NoPlatformClientException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptLoginRequiredException> { e ->
|
||||
Logger.w(TAG, "exception<ScriptLoginRequiredException>", e)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", {
|
||||
val id = e.config.let { if (it is SourcePluginConfig) it.id else null }
|
||||
val didLogin =
|
||||
if (id == null) false else StatePlugins.instance.loginPlugin(context, id) {
|
||||
loadVideo(url)
|
||||
}
|
||||
if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login")
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ContentNotAvailableYetException> {
|
||||
Logger.w(TAG, "exception<ContentNotAvailableYetException>", it)
|
||||
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
|
||||
}.exception<ScriptImplementationException> {
|
||||
Logger.w(TAG, "exception<ScriptImplementationException>", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
|
||||
}.exception<ScriptAgeException> {
|
||||
Logger.w(TAG, "exception<ScriptAgeException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptUnavailableException> {
|
||||
Logger.w(TAG, "exception<ScriptUnavailableException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptException> {
|
||||
Logger.w(TAG, "exception<ScriptException>", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
|
||||
}
|
||||
|
||||
loadVideoTask?.run(url)
|
||||
}
|
||||
|
||||
private fun playVideo(resumePositionMs: Long = 0) {
|
||||
val videoDetails = this@ShortView.videoDetails
|
||||
|
||||
if (videoDetails === null) {
|
||||
playWhenReady = true
|
||||
return
|
||||
}
|
||||
|
||||
updateQualitySourcesOverlay(videoDetails, null)
|
||||
|
||||
try {
|
||||
val videoSource = _lastVideoSource
|
||||
?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount())
|
||||
val audioSource = _lastAudioSource
|
||||
?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context))
|
||||
val subtitleSource = _lastSubtitleSource
|
||||
?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null)
|
||||
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
|
||||
|
||||
if (videoSource == null && audioSource == null) {
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
StatePlatform.instance.clearContentDetailCache(videoDetails.url)
|
||||
return
|
||||
}
|
||||
|
||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||
/*
|
||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
player.setArtwork(resource.toDrawable(resources))
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
player.setArtwork(null)
|
||||
}
|
||||
})
|
||||
else player.setArtwork(null)
|
||||
*/
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
|
||||
if (subtitleSource != null) player.swapSubtitles(subtitleSource)
|
||||
player.seekTo(resumePositionMs)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "playVideo failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastVideoSource = videoSource
|
||||
_lastAudioSource = audioSource
|
||||
_lastSubtitleSource = subtitleSource
|
||||
} catch (ex: UnsupportedCastException) {
|
||||
Logger.e(TAG, "Failed to load cast media", ex)
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex)
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load media", ex)
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "VideoDetailView"
|
||||
}
|
||||
|
||||
}
|
||||
-370
@@ -1,370 +0,0 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@UnstableApi
|
||||
class ShortsFragment : MainFragment() {
|
||||
override val isMainView: Boolean = true
|
||||
override val isTab: Boolean = true
|
||||
override val hasBottomBar: Boolean get() = true
|
||||
|
||||
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
|
||||
private var nextPageTask: TaskHandler<ShortsFragment, List<IPlatformVideo>>? = null
|
||||
|
||||
//TODO: Reduce number of pagers (1, or at most 2)
|
||||
private var mainShortsPager: IPager<IPlatformVideo>? = null
|
||||
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
|
||||
// the pager to call next on
|
||||
private var currentShortsPager: IPager<IPlatformVideo>? = null
|
||||
|
||||
// the shorts array bound to the ViewPager2 adapter
|
||||
private val currentShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
|
||||
private var channelShortsPager: IPager<IPlatformVideo>? = null
|
||||
private val channelShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
val isChannelShortsMode: Boolean
|
||||
get() = channelShortsPager != null
|
||||
|
||||
private var viewPager: ViewPager2? = null
|
||||
private lateinit var zeroState: LinearLayout
|
||||
private lateinit var sourcesButton: BigButton
|
||||
private lateinit var overlayLoading: FrameLayout
|
||||
private lateinit var overlayLoadingSpinner: ImageView
|
||||
private lateinit var overlayQualityContainer: FrameLayout
|
||||
private var customViewAdapter: CustomViewAdapter? = null
|
||||
|
||||
// we just completely reset the data structure so we want to tell the adapter that
|
||||
//TODO: Move most of this logic to ShortsView
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||
super.onShownWithView(parameter, isBack)
|
||||
|
||||
if (parameter is Triple<*, *, *>) {
|
||||
setLoading(false)
|
||||
channelShorts.clear()
|
||||
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||
channelShorts.addAll(parameter.third as ArrayList<IPlatformVideo>)
|
||||
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||
channelShortsPager = parameter.second as IPager<IPlatformVideo>
|
||||
|
||||
currentShorts.clear()
|
||||
currentShorts.addAll(channelShorts)
|
||||
currentShortsPager = channelShortsPager
|
||||
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
|
||||
viewPager?.post {
|
||||
viewPager?.currentItem = channelShorts.indexOfFirst {
|
||||
return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id
|
||||
}
|
||||
}
|
||||
} else if (isChannelShortsMode) {
|
||||
channelShortsPager = null
|
||||
channelShorts.clear()
|
||||
currentShorts.clear()
|
||||
|
||||
if (loadPagerTask == null) {
|
||||
currentShorts.addAll(mainShorts)
|
||||
currentShortsPager = mainShortsPager
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
viewPager?.currentItem = 0
|
||||
}
|
||||
|
||||
updateZeroState()
|
||||
}
|
||||
|
||||
override fun onCreateMainView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_shorts, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewPager = view.findViewById(R.id.view_pager)
|
||||
zeroState = view.findViewById(R.id.zero_state)
|
||||
sourcesButton = view.findViewById(R.id.sources_button)
|
||||
overlayLoading = view.findViewById(R.id.short_view_loading_overlay)
|
||||
overlayLoadingSpinner = view.findViewById(R.id.short_view_loader)
|
||||
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
|
||||
|
||||
sourcesButton.onClick.subscribe {
|
||||
navigate<SourcesFragment>()
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
Logger.i(TAG, "Creating adapter")
|
||||
val customViewAdapter =
|
||||
CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) {
|
||||
if (!currentShortsPager!!.hasMorePages()) {
|
||||
return@CustomViewAdapter
|
||||
}
|
||||
nextPage()
|
||||
}
|
||||
customViewAdapter.onResetTriggered.subscribe {
|
||||
setLoading(true)
|
||||
loadPager()
|
||||
|
||||
loadPagerTask!!.success {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
val viewPager = viewPager!!
|
||||
viewPager.adapter = customViewAdapter
|
||||
|
||||
this.customViewAdapter = customViewAdapter
|
||||
|
||||
if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
|
||||
loadPager()
|
||||
|
||||
loadPagerTask!!.success {
|
||||
setLoading(false)
|
||||
updateZeroState()
|
||||
}
|
||||
} else {
|
||||
setLoading(false)
|
||||
updateZeroState()
|
||||
}
|
||||
|
||||
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
fun play(adapter: CustomViewAdapter, position: Int) {
|
||||
val recycler = (viewPager.getChildAt(0) as RecyclerView)
|
||||
val viewHolder =
|
||||
recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder?
|
||||
|
||||
if (viewHolder == null) {
|
||||
adapter.needToPlay = position
|
||||
} else {
|
||||
val focusedView = viewHolder.shortView
|
||||
focusedView.play()
|
||||
adapter.previousShownView = focusedView
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||
if (adapter.previousShownView == null) {
|
||||
// play if this page selection didn't trigger by a swipe from another page
|
||||
play(adapter, position)
|
||||
} else {
|
||||
adapter.previousShownView?.stop()
|
||||
adapter.previousShownView = null
|
||||
adapter.newPosition = position
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the state to idle to prevent UI lag
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
super.onPageScrollStateChanged(state)
|
||||
if (state == ViewPager2.SCROLL_STATE_IDLE) {
|
||||
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||
val position = adapter.newPosition ?: return
|
||||
adapter.newPosition = null
|
||||
|
||||
play(adapter, position)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateZeroState() {
|
||||
if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) {
|
||||
zeroState.visibility = View.VISIBLE
|
||||
} else {
|
||||
zeroState.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextPage() {
|
||||
Logger.i(TAG, "ShortsFragment nextPage");
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val time = measureTimeMillis {
|
||||
currentShortsPager!!.nextPage();
|
||||
}
|
||||
val newVideos = currentShortsPager!!.getResults();
|
||||
val prevCount = customViewAdapter!!.itemCount
|
||||
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
|
||||
currentShorts.addAll(newVideos)
|
||||
if (isChannelShortsMode) {
|
||||
channelShorts.addAll(newVideos)
|
||||
} else {
|
||||
mainShorts.addAll(newVideos)
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
|
||||
}
|
||||
nextPageTask = null
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we just completely reset the data structure so we want to tell the adapter that
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun loadPager() {
|
||||
loadPagerTask?.cancel()
|
||||
|
||||
Logger.i(TAG, "Shorts loadPage");
|
||||
var loadPageStart = System.currentTimeMillis();
|
||||
val loadPagerTask =
|
||||
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
|
||||
val pager = StatePlatform.instance.getShorts();
|
||||
|
||||
return@TaskHandler pager
|
||||
}).success { pager ->
|
||||
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
|
||||
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
|
||||
mainShorts.clear()
|
||||
mainShorts.addAll(pager.getResults())
|
||||
mainShortsPager = pager
|
||||
|
||||
if (!isChannelShortsMode) {
|
||||
currentShorts.clear()
|
||||
currentShorts.addAll(mainShorts)
|
||||
currentShortsPager = pager
|
||||
|
||||
// if the view pager exists go back to the beginning
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
viewPager?.currentItem = 0
|
||||
}
|
||||
|
||||
loadPagerTask = null
|
||||
}.exception<Throwable> { err ->
|
||||
val message = "Unable to load shorts $err"
|
||||
Logger.w(TAG, message, err)
|
||||
if (context != null) {
|
||||
UIDialogs.showDialog(
|
||||
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
|
||||
"Close", { }, UIDialogs.ActionStyle.PRIMARY
|
||||
)
|
||||
)
|
||||
}
|
||||
return@exception
|
||||
}
|
||||
|
||||
this.loadPagerTask = loadPagerTask
|
||||
|
||||
loadPagerTask.run(this)
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
overlayLoading.visibility = View.VISIBLE
|
||||
} else {
|
||||
overlayLoading.visibility = View.GONE
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
customViewAdapter?.previousShownView?.pause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
loadPagerTask?.cancel()
|
||||
loadPagerTask = null
|
||||
nextPageTask?.cancel()
|
||||
nextPageTask = null
|
||||
customViewAdapter?.previousShownView?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShortsFragment"
|
||||
|
||||
fun newInstance() = ShortsFragment()
|
||||
}
|
||||
|
||||
class CustomViewAdapter(
|
||||
private val videos: MutableList<IPlatformVideo>,
|
||||
private val inflater: LayoutInflater,
|
||||
private val fragment: MainFragment,
|
||||
private val overlayQualityContainer: FrameLayout,
|
||||
private val isChannelShortsMode: () -> Boolean,
|
||||
private val onNearEnd: () -> Unit,
|
||||
) : RecyclerView.Adapter<CustomViewHolder>() {
|
||||
val onResetTriggered = Event0()
|
||||
var previousShownView: ShortView? = null
|
||||
var newPosition: Int? = null
|
||||
var needToPlay: Int? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
|
||||
val shortView = ShortView(inflater, fragment, overlayQualityContainer)
|
||||
shortView.onResetTriggered.subscribe {
|
||||
onResetTriggered.emit()
|
||||
}
|
||||
return CustomViewHolder(shortView)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
|
||||
Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})")
|
||||
holder.shortView.changeVideo(videos[position], isChannelShortsMode())
|
||||
|
||||
if (position == itemCount - 1) {
|
||||
onNearEnd()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: CustomViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
holder.shortView.cancel()
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: CustomViewHolder) {
|
||||
super.onViewAttachedToWindow(holder)
|
||||
|
||||
if (holder.absoluteAdapterPosition == needToPlay) {
|
||||
holder.shortView.play()
|
||||
needToPlay = null
|
||||
previousShownView = holder.shortView
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = videos.size
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView)
|
||||
}
|
||||
-45
@@ -25,7 +25,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
@@ -153,50 +152,6 @@ class SourceDetailFragment : MainFragment() {
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
|
||||
val field = _settingsAppForm.findField("sync");
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
else {
|
||||
val field = _settingsAppForm.findField("syncHistory");
|
||||
field?.onChanged?.subscribe { field, new, old ->
|
||||
if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?",
|
||||
"This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
|
||||
}),
|
||||
UIDialogs.Action("Yes", {
|
||||
UIDialogs.showDialogProgress(context, {
|
||||
it.setText("Importing history..");
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = StatePlatform.instance.getClient(config.id);
|
||||
if (client != null && client is JSClient) {
|
||||
val count = StateHistory.instance.syncRemoteHistory(client);
|
||||
withContext(Dispatchers.Main) {
|
||||
it.hide();
|
||||
if(count > 0)
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items");
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.appToast("Sync History failed due to:\n" + ex.message);
|
||||
it.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
_settingsAppForm.onChanged.clear();
|
||||
_settingsAppForm.onChanged.subscribe { field, value ->
|
||||
_settingsAppChanged = true;
|
||||
|
||||
+2
-2
@@ -25,10 +25,10 @@ data class SuggestionsFragmentData(val query: String, val searchType: SearchType
|
||||
|
||||
class SuggestionsFragment : MainFragment {
|
||||
override val isMainView : Boolean = true;
|
||||
override val hasBottomBar: Boolean = true;
|
||||
override val hasBottomBar: Boolean = false;
|
||||
override val isHistory: Boolean = false;
|
||||
|
||||
private var _recyclerSuggestions: RecyclerView? = null;
|
||||
private var _recyclerSuggestions: RecyclerView? = null;
|
||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||
private var _radioGroupView: RadioGroupView? = null;
|
||||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user