Compare commits

...

73 Commits

Author SHA1 Message Date
Koen J 226d5c1c68 Forgot to commit resource file. 2025-08-01 10:45:38 +02:00
Koen J 8b36865f5e Unpushed changes. 2025-08-01 10:45:06 +02:00
Koen J c3be5f6dc5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into airplay2 2025-06-24 13:08:35 +02:00
Koen J 4c0eceaa8e Initial implementation of AirPlay2 pairing. 2025-06-24 13:05:17 +02:00
Koen J 7f20250951 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-24 11:43:59 +02:00
Koen J 4d720b1d81 Fixed app freezing when exporting Polycentric Identity #2405 2025-06-24 11:43:40 +02:00
Kelvin K a8921a1aba Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-20 15:35:30 +02:00
Kelvin K edb9eda0a9 Improved locking 2025-06-20 15:35:02 +02:00
Koen J 3a81676447 Fixed crash #2389. 2025-06-20 10:47:10 +02:00
Koen J 03132ff77b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-19 11:23:28 +02:00
Koen J 49ddecdea4 Potential crashfix #2382. 2025-06-19 11:21:46 +02:00
Kelvin K 44ff951ec6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 16:22:51 +02:00
Kelvin K 11319e0ec5 Refs 2025-06-18 16:22:28 +02:00
Koen J 100e98a960 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 15:43:45 +02:00
Koen J c6100ede70 Added disable for hold playback rate increase. 2025-06-18 15:43:12 +02:00
Kelvin K a2986a72bd Refs 2025-06-18 14:43:20 +02:00
Kelvin K e0e90c5f74 submodules 2025-06-18 14:33:07 +02:00
Kelvin K 11992af81b Hide duration if unknown 2025-06-18 14:27:20 +02:00
Koen J 15d771f7fc Fixed channel loader not being animated. 2025-06-18 13:43:50 +02:00
Kelvin K 5ede474253 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 12:41:43 +02:00
Kelvin K 7922aa6f80 Log on busy on main 2025-06-18 12:41:21 +02:00
Kelvin K 0c1333fa15 Downgrade v8, revert comments on diff thread 2025-06-18 12:40:25 +02:00
Koen J 53b9ba0368 Reverted changes. 2025-06-18 10:29:12 +02:00
Koen J c3a8877796 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 10:07:29 +02:00
Koen J a464ae9df5 Added missing loader causing crash. 2025-06-18 10:07:02 +02:00
Kelvin K 0d16dd0006 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-17 17:28:17 +02:00
Kelvin K 48a96140a7 isBusy checks and locking improvements 2025-06-17 17:28:10 +02:00
Kelvin 603ef8f295 Merge branch 'fix-timeout-locking' into 'master'
fix: timeoutMap being deadlocked

See merge request videostreaming/grayjay!126
2025-06-17 15:26:51 +00:00
zvonimir ab07288ba0 fix: timeoutMap being deadlocked 2025-06-17 17:25:34 +02:00
Kelvin K c0bbe5d491 Additional locking 2025-06-17 15:21:46 +02:00
Kelvin K b953ff21e7 Lock on subtitle fetch 2025-06-17 11:52:26 +02:00
Kelvin K c14378b534 Improved V8 locking, comment section on diff thread than video, global mapping of v8runtimes to plugins 2025-06-17 11:45:02 +02:00
Kelvin K 33d3d9a29c Improved locking 2025-06-16 19:30:52 +02:00
Kelvin K 7e83793586 Submods 2025-06-16 18:34:37 +02:00
Kelvin K 6ba9ec8bc2 Clearer name setting 2025-06-16 17:56:04 +02:00
Kelvin 0b02ab0e2d Merge branch 'plugin-fixes' into 'master'
V8 Update, V8 interaction locking, Package fixes, ReloadRequiredException support

See merge request videostreaming/grayjay!125
2025-06-16 15:48:01 +00:00
Kelvin K ff531b5e77 Cleanup, fixes, clearCookies support on httpClients 2025-06-16 17:46:00 +02:00
Kelvin K b3f9de3b83 edgecase fix 2025-06-16 14:23:34 +02:00
Kelvin K 86bd71b89c Fix edgecase 2025-06-16 14:19:23 +02:00
Kelvin K 2fca7e9a01 Locking of most known v8 interactions, fix returning previously returned jvm objects, Related fixes 2025-06-16 14:13:47 +02:00
Koen 2cc873ef60 Merge branch 'quality-selector-fix' into 'master'
fix graphical glitches with quality selector

See merge request videostreaming/grayjay!109
2025-06-16 10:07:16 +00:00
Koen 7a66ce6bcd Merge branch 'sources-tab-scrolling-fix' into 'master'
Sources Scrolling Fix

See merge request videostreaming/grayjay!114
2025-06-16 10:01:45 +00:00
Koen 2730569b6b Merge branch 'tablet-landscape-fix' into 'master'
Tablet Landscape Fix

See merge request videostreaming/grayjay!115
2025-06-16 09:57:57 +00:00
Koen ede5c4409c Merge branch 'watch-later-add-feature' into 'master'
Water Later Add Feature

See merge request videostreaming/grayjay!117
2025-06-16 09:54:43 +00:00
Koen 0dbe398435 Merge branch 'hls-quality-sort' into 'master'
Adaptive Quality Sort

See merge request videostreaming/grayjay!118
2025-06-16 09:41:22 +00:00
Koen J bcab3bccbc Fixed crash when signature fields are wrongly populated. 2025-06-16 10:43:57 +02:00
Kelvin K 58c9aeb1a2 WIP: V8 update, package http fixes, ReloadRequiredException support, other fixes. Currently broken in situations where setTimeout is used 2025-06-14 15:51:31 +02:00
Kelvin K 4702787784 WIP 2025-06-13 17:47:22 +02:00
Koen J 13100dc38d Minor fix in playback speed setting. 2025-06-12 11:21:00 +02:00
Koen J 5227041398 Added setting for hold playback speed increase. Implemented chromecast playback rate adjustment in range [1, 2]. Implemented hold playback speed increase pill. 2025-06-12 10:33:05 +02:00
Kelvin 8491d4da1a Merge branch 'fix-ump-downloads' into 'master'
Revert downloads patch which broke downloads

See merge request videostreaming/grayjay!122
2025-06-11 16:41:20 +00:00
zvonimir 9bea1563ca Revert downloads patch which broke downloads 2025-06-11 18:36:05 +02:00
Koen J 9e7b936663 Implemented hold to play video at 2x speed gesture. 2025-06-11 17:03:53 +02:00
Kelvin 19c84475db Hotfix playback speed for non-dot locales 2025-06-10 23:27:01 +02:00
Kelvin 4164b1a3f8 Build fix 2025-06-10 19:25:43 +02:00
Kelvin a9dc038190 Build fix 2025-06-10 19:20:50 +02:00
Kelvin 2825db88a5 Minor playback tracker fix, submodules 2025-06-10 18:56:19 +02:00
Kelvin 363099b303 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-10 17:39:44 +02:00
Kelvin 5e25a5054f Increase max comment length, Fix raw dash downloads ending too early, Fix playback tracker not working for downloaded videos 2025-06-10 17:33:14 +02:00
Kelvin 2bc6127f6b Merge branch 'copy-title' into 'master'
Copy Title

See merge request videostreaming/grayjay!110
2025-06-10 14:41:49 +00:00
Kelvin 064824aedf Merge branch 'copy-playlists' into 'master'
Clone Playlist

See merge request videostreaming/grayjay!111
2025-06-10 14:41:07 +00:00
Kai DeLorenzo 52044edb2e Merge branch 'brightness-fix' into 'master'
Dim Fullscreen Fix

See merge request videostreaming/grayjay!119
2025-06-10 14:38:01 +00:00
Kai fb12073a82 Only save brightness on resume fullscreen if use system brightness is enabled
Changelog: changed
2025-06-10 09:18:28 -05:00
Kai 9944842a2f Change adaptive streaming (HLS and Dash) quality to sort in descending quality to align with YouTube and the rest of Grayjay
Changelog: changed
2025-06-09 17:02:55 -05:00
Kai 99dc50894c update text
Changelog: changed
2025-06-09 16:54:24 -05:00
Kai 6598dff6df add add to watch later setting
add https://github.com/futo-org/grayjay-android/issues/2173

Changelog: added
2025-06-06 23:35:59 -05:00
Kai 389798457b navigate to playlist screen after copying
Changelog: changed
2025-06-06 15:57:09 -05:00
Kai 623c47fa2e fix https://github.com/futo-org/grayjay-android/issues/2210
Changelog: changed
2025-06-06 15:25:46 -05:00
Kai 19861fe812 fix https://github.com/futo-org/grayjay-android/issues/2316
Changelog: changed
2025-06-06 13:40:20 -05:00
Kai dd1c04bea1 make the copied playlist name unique
Changelog: changed
2025-06-06 09:39:09 -05:00
Kai 8e70f1b865 add long tap to copy playing video title
Changelog: added
2025-06-05 23:14:03 -05:00
Kai f86fb0ee44 add functionality to copy playlists
fix https://github.com/futo-org/grayjay-android/issues/2306

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

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