mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f1c0209a8 | |||
| 819e81b7a6 | |||
| 8193234c2f | |||
| 6263a31f41 | |||
| 481a0cda99 | |||
| b39b89e908 | |||
| ce0f98055f | |||
| 3dddf68766 | |||
| 88d687f26e | |||
| d44df42727 | |||
| 88c8dbcb7c | |||
| b4fddbe26a | |||
| ab6d7669d7 | |||
| 3f22c7f717 | |||
| f36e9588cb | |||
| 8f99f399ee | |||
| 56166a7948 | |||
| 4edd8ee1ea | |||
| a830c918ab | |||
| 53f74c4b6e | |||
| 959c192762 | |||
| 8be7b1272b | |||
| 6b57878275 | |||
| 66c7741c38 | |||
| b370af9d91 | |||
| 40b86cb5de | |||
| 84622e22aa | |||
| 092b20041e | |||
| f6cc00f471 | |||
| be2067067b | |||
| 67a7dd9698 | |||
| 6ffc067b24 | |||
| 56e6314c11 | |||
| e590bb4a19 | |||
| 35fe7f0e7a | |||
| 45d818ac81 | |||
| 7729681829 | |||
| b12d04b27d | |||
| e6608b9a5c | |||
| 2d503dfaf6 | |||
| 08934ef8de | |||
| 62d927739a | |||
| c8db8f58e8 | |||
| 0fc966a77d | |||
| 9f6c6c8cf3 | |||
| 43a6ff138c | |||
| 269a3460e7 | |||
| 18150e9e15 | |||
| 362c7f5b2c | |||
| 2adb8ad7f9 | |||
| 6b5d4e7507 | |||
| 49c82726f0 | |||
| c8ddcda384 | |||
| b75217f789 | |||
| 8ba8e535bd | |||
| e4c574db6b | |||
| fae73293d7 | |||
| 3bd0aac4f8 | |||
| 26b822e04b | |||
| 96b9b8843c | |||
| 6d9c1e17b5 | |||
| 507ad105c0 | |||
| 40a283017e | |||
| be14597670 | |||
| 837609abb9 | |||
| d64cd98b43 | |||
| 0081ff1483 | |||
| f78ca6c7ed | |||
| cfc7cbcaa4 | |||
| e533eb7778 | |||
| 7c1d0a7f88 | |||
| 01ef471708 | |||
| 2fd0a9a41d | |||
| 635749dfe4 | |||
| c4bd5626f3 | |||
| 568a0f6329 | |||
| 7ee67b5cd0 | |||
| fc94c6903c | |||
| a0af8805e7 | |||
| 9b64cde17d | |||
| f6931bcf8c | |||
| a4ff47d863 | |||
| 982d251126 | |||
| 8820a0ecc0 | |||
| b99a713ffc | |||
| dfc8c4b740 | |||
| c3df9e5259 | |||
| b9c7e0a8ca | |||
| 2c7f02a24d | |||
| 5cc8488d94 | |||
| 6f7304f59c | |||
| ea4fea4401 | |||
| 9b48664de4 | |||
| 8964dc68f0 | |||
| 4711b8055b | |||
| 84e3373fa7 | |||
| fdd7e32dd8 | |||
| e57119ebbd | |||
| ed29dd8365 | |||
| 196cacb452 | |||
| c025913fc8 | |||
| 48b2c68e72 | |||
| 689766a6ac | |||
| 9306024d17 | |||
| 195163840b | |||
| 788c54bf8f | |||
| 031aabd523 | |||
| 85db4cc4e6 | |||
| 745aad385b | |||
| ba87261f9f | |||
| 7d091382c0 | |||
| 781d0797e7 | |||
| ec12a06b88 | |||
| bf3e8867c3 | |||
| 176814a715 | |||
| 898637a616 | |||
| f1860126a7 | |||
| f8402676d7 | |||
| cf86ce1ab3 | |||
| f4cb1719e0 | |||
| 4898cb53ae | |||
| 0f60d4737e | |||
| 0dc33e1f2b | |||
| d86a997a88 | |||
| 34d4d92289 | |||
| 4cb1bf268f | |||
| 8488706ff9 | |||
| a348bb2662 | |||
| 60a17b3c67 | |||
| 386c58d4ad | |||
| 356ba01dc1 | |||
| ed2aa848da | |||
| c5dd90048f | |||
| ab04f334dc | |||
| 0d44f8a416 | |||
| d01a1545e2 | |||
| e599729ba1 | |||
| 3ac043740e | |||
| 89603d0ff3 | |||
| 05b6cd7c97 | |||
| ea5aad0631 | |||
| 96e034b9bf | |||
| 6141c36855 | |||
| 4084ab3ed0 | |||
| 34e733823a | |||
| f1d01642cd | |||
| d5551d7118 | |||
| d079a1e8e4 | |||
| c06c00ee9b | |||
| 1d8eababc2 | |||
| 75cf1ffbdd | |||
| 5499706a9b | |||
| ba57e32920 | |||
| df96c5b51c | |||
| 75f81d20db | |||
| 3fc92e4065 | |||
| 8ffd5f411f | |||
| 918161a299 | |||
| 9f50f72eaa | |||
| 2f66f124aa | |||
| 9a11717cf4 | |||
| 0d80424799 | |||
| ed9a65b2f0 | |||
| 8a53297be2 | |||
| 20862a27c8 | |||
| 95785e6c78 | |||
| e88c649578 | |||
| 09f91e64fb | |||
| b8923e59a1 | |||
| e722c0ce9a | |||
| 56248bf4b0 | |||
| 5af4787c45 | |||
| 0990247322 | |||
| 0154525578 | |||
| 1dc6eee242 | |||
| c63a63cb33 | |||
| c1967556ac | |||
| 309a57f5a1 | |||
| ee0bc96e53 | |||
| a4422fdd56 | |||
| b7c4047f1d | |||
| 65174ffc97 | |||
| eac3e37af5 | |||
| 0d5ad90ff9 | |||
| f42b14e95a | |||
| b8acd0b5b2 | |||
| ef72561768 | |||
| d63627bd61 | |||
| 422cceb225 | |||
| 76f5962232 | |||
| 30df22d225 | |||
| cd4295be59 | |||
| 7d366110b1 | |||
| 35c5045b3f | |||
| 4930ea8183 | |||
| 02292fed04 | |||
| bf6e61ed90 | |||
| 2ac8e0e621 | |||
| 0432f06eb3 | |||
| 7bfab8409f | |||
| 52d833d726 | |||
| 14d579eb1b | |||
| d3ab8ecf3a | |||
| 627b8c2b5d | |||
| 7f1cb22c12 | |||
| 5551bd31fe | |||
| 189d855c3f | |||
| 0ab52e8f4d | |||
| 27eb5aa6e1 | |||
| 49b5b16641 | |||
| 73dd52af28 | |||
| 3b8d256bad | |||
| 5d7dc1fdcb | |||
| f31b6c50e9 | |||
| fa12f8277c |
+6
-3
@@ -1,9 +1,6 @@
|
||||
[submodule "dep/polycentricandroid"]
|
||||
path = dep/polycentricandroid
|
||||
url = ../polycentricandroid.git
|
||||
[submodule "app/src/playstore/assets/sources/peertube"]
|
||||
path = app/src/playstore/assets/sources/peertube
|
||||
url = ../plugins/peertube.git
|
||||
[submodule "app/src/stable/assets/sources/kick"]
|
||||
path = app/src/stable/assets/sources/kick
|
||||
url = ../plugins/kick.git
|
||||
@@ -61,3 +58,9 @@
|
||||
[submodule "dep/futopay"]
|
||||
path = dep/futopay
|
||||
url = ../futopayclientlibraries.git
|
||||
[submodule "app/src/unstable/assets/sources/bilibili"]
|
||||
path = app/src/unstable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
|
||||
+12
-11
@@ -151,7 +151,7 @@ dependencies {
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
|
||||
//Images
|
||||
@@ -169,18 +169,19 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:2.2.1")
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
|
||||
//Exoplayer
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-dash:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-rtsp:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.19.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-transformer:2.19.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
|
||||
//Other
|
||||
implementation 'org.jmdns:jmdns:3.5.1'
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.Opcode
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FCastEncryptionTests {
|
||||
@Test
|
||||
fun testDHEncryptionSelf() {
|
||||
val keyPair1 = FCastCastingDevice.generateKeyPair()
|
||||
val keyPair2 = FCastCastingDevice.generateKeyPair()
|
||||
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
|
||||
|
||||
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
|
||||
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
|
||||
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
|
||||
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
|
||||
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
|
||||
|
||||
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
|
||||
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
|
||||
|
||||
val message = FCastPlayMessage("text/html")
|
||||
val serializedBody = Json.encodeToString(message)
|
||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
||||
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
|
||||
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
||||
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals(serializedBody, decryptedMessage.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAESKeyGeneration() {
|
||||
val cases = listOf(
|
||||
listOf(
|
||||
//Public other
|
||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
|
||||
//Private self
|
||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
|
||||
//AES
|
||||
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
|
||||
),
|
||||
listOf(
|
||||
//Public other
|
||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
|
||||
//Private self
|
||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
|
||||
//AES
|
||||
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
|
||||
)
|
||||
)
|
||||
|
||||
for (case in cases) {
|
||||
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
|
||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
||||
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDHEncryptionKnown() {
|
||||
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
|
||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
||||
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
||||
|
||||
val message = FCastPlayMessage("text/html")
|
||||
val serializedBody = Json.encodeToString(message)
|
||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
||||
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals(serializedBody, decryptedMessage.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecryptMessageKnown() {
|
||||
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
|
||||
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -24,7 +25,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_287_2206)">
|
||||
<path d="M22.0557 38.25L43.1117 6H1L22.0557 38.25Z" fill="url(#paint0_linear_287_2206)"/>
|
||||
<path d="M6 28.2444C6.85811 27.3291 8.98625 25.2353 10.6338 24.1827C12.2814 23.13 14.257 20.1209 15.0388 18.7479C17.4224 15.2392 22.7618 7.91286 25.0501 6.67716C25.462 6.35678 26.0608 5.85718 26.3087 5.64745C27.1668 3.7405 30.0844 0.498738 34.8898 2.78706C35.3017 2.64974 36.32 2.61542 36.7777 2.61542C36.4153 2.86334 35.6564 3.58795 35.5191 4.50328C35.153 7.02039 33.7647 8.48874 33.1164 8.90825C32.6587 11.8259 32.0294 14.4002 30.6564 15.3155L31.915 17.5466C33.8029 19.5489 37.7159 23.8737 38.2649 25.1552C36.4344 24.5603 35.2521 23.992 34.8898 23.7822L38.2649 28.416C36.2818 28.2635 31.8235 26.9744 29.8556 23.0385C30.6336 25.1438 31.4001 27.7677 31.6862 28.8165C30.6183 27.9393 28.3224 25.3955 27.6816 22.2376C27.8647 25.304 27.8342 27.4816 27.7961 28.1872C27.2812 27.7105 26.0913 26.2307 25.4505 24.1255V27.6723C24.6821 26.604 23.1363 24.0104 22.9967 22.0533C23.1255 24.2716 23.047 25.3115 22.9906 25.5556L20.0731 22.8097C19.2912 23.2292 17.1898 24.1827 15.0388 24.6403C13.5743 25.876 11.797 28.969 11.0915 30.3611V28.5877L9.14643 30.5327L9.83291 28.4733L8.57433 29.5602C8.28828 29.7318 7.62468 30.0751 7.25857 30.0751C7.39585 29.7547 7.65904 29.4076 7.77345 29.2741L6.11441 29.9034C6.3051 29.3504 6.90388 28.13 7.77345 27.6723C6.58351 28.13 6.09536 28.2444 6 28.2444Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_287_2206" x1="22.0557" y1="38.25" x2="22.0557" y2="-4.75" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01D6E6"/>
|
||||
<stop offset="1" stop-color="#0182E7"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_287_2206">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -233,6 +233,9 @@ function pluginRemoteProp(objID, propName) {
|
||||
function pluginRemoteCall(objID, methodName, args) {
|
||||
return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
|
||||
}
|
||||
function pluginRemoteTest(methodName, args) {
|
||||
return JSON.parse(syncPOST("/plugin/remoteTest?method=" + methodName, {}, JSON.stringify(args)));
|
||||
}
|
||||
|
||||
function pluginIsLoggedIn(cb, err) {
|
||||
fetch("/plugin/isLoggedIn", {
|
||||
@@ -259,6 +262,17 @@ function getDevLogs(lastIndex, cb) {
|
||||
.then(x=>x.json())
|
||||
.then(y=> cb && cb(y));
|
||||
}
|
||||
function getDevHttpExchanges(cb) {
|
||||
fetch("/plugin/getDevHttpExchanges", {
|
||||
timeout: 1000
|
||||
})
|
||||
.then(x=>x.json())
|
||||
.then(y=> cb && cb(y));
|
||||
}
|
||||
function setDevHttpProxy(url, port) {
|
||||
return fetch("/dev/setDevProxy?url=" + encodeURIComponent(url) + "&port=" + port)
|
||||
.then(x=>x.json());
|
||||
}
|
||||
function sendFakeDevLog(devId, msg) {
|
||||
return syncGET("/plugin/fakeDevLog?devId=" + devId + "&msg=" + msg, {});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<!--<link href="./dependencies/vuetify.min.css" rel="stylesheet">-->
|
||||
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.7.1/dist/vuetify.min.css" rel="stylesheet">
|
||||
|
||||
<title>DevPortal</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.svg">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
|
||||
|
||||
<style>
|
||||
@@ -150,7 +153,7 @@
|
||||
.pastPluginUrl {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 500px;
|
||||
width: 700px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@@ -160,13 +163,122 @@
|
||||
box-shadow: 0px 1px 2px #131313;
|
||||
font-weight: lighter;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.pastPluginUrl .deleteButton {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
height: 100%;
|
||||
width: 30px;
|
||||
top: 0px;
|
||||
padding-top: 2px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
transform: scaleX(1.5);
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
#cloakLoader {
|
||||
display: block;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding-top: 50px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.httpContainer {
|
||||
position: relative;
|
||||
}
|
||||
.httpLine {
|
||||
}
|
||||
.httpLine .request {
|
||||
height: 50px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.httpLine .request .status {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
width: 40px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.httpLine .request .status.error {
|
||||
background-color: #880000;
|
||||
}
|
||||
.httpLine .request .status.success {
|
||||
background-color: #008800;
|
||||
}
|
||||
.httpLine .request .status.warn {
|
||||
background-color: #803500;
|
||||
}
|
||||
.httpLine .request .method {
|
||||
position: absolute;
|
||||
left: 55px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.httpLine .request .url {
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.httpLine .response {
|
||||
background-color: #111;
|
||||
margin-left: 55px;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.httpLine .response .body{
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background-color: black;
|
||||
padding: 10px;
|
||||
}
|
||||
.httpLine .response .headers {
|
||||
margin: 10px;
|
||||
}
|
||||
.httpLine .response .headers .key {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #FFF;
|
||||
}
|
||||
.httpLine .response .headers .value {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
color: #AAA;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<v-app>
|
||||
<v-main>
|
||||
<div v-cloak id="cloakLoader" v-if="!page">
|
||||
<h2>Loading..</h2>
|
||||
First load may take longer
|
||||
</div>
|
||||
<v-main v-cloak>
|
||||
<div id="topMenu">
|
||||
<div style="height: 100%; display: inline-block; padding-left: 10px; padding-right: 20px;">
|
||||
<img src="./dependencies/FutoMainLogo.svg"
|
||||
@@ -250,10 +362,13 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="pastPluginUrls" style="margin-top: 60px;">
|
||||
<div v-if="pastPluginUrls" style="margin-top: 60px; margin-left: 25px;">
|
||||
<h2 style="font-weight: lighter; text-align: center;">Past Plugins</h2>
|
||||
<div class="pastPluginUrl" v-for="pastPluginUrl in pastPluginUrls" @click="this.Plugin.newPluginUrl = pastPluginUrl; loadPlugin(pastPluginUrl)">
|
||||
{{pastPluginUrl}}
|
||||
<div class="deleteButton" @click="(ev)=>{ev.stopPropagation(); deletePastPlugin(pastPluginUrl)}">
|
||||
X
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,8 +500,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div style="width: 50%" v-if="Plugin.currentPlugin">
|
||||
<!--Get Home-->
|
||||
<v-card class="requestCard" v-for="req in Testing.requests">
|
||||
<v-text-field v-model="searchTestMethods" label="Search for source methods.." style="margin-left: 35px; margin-right: 35px;"></v-text-field>
|
||||
<v-card class="requestCard" v-for="req in Testing.requests" v-show="req.title.indexOf(searchTestMethods) >= 0">
|
||||
<v-card-text>
|
||||
<div class="title">
|
||||
<span v-if="req.isOptional">(Optional)</span>
|
||||
@@ -402,6 +517,11 @@
|
||||
<div class="code">
|
||||
{{req.code}}
|
||||
</div>
|
||||
<div class="documentation" v-if="req.docUrl" style="position: absolute; right: 15px; top: 15px;">
|
||||
<a :href="req.docUrl" target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="parameter" v-for="parameter in req.parameters">
|
||||
<div class="name">
|
||||
@@ -416,6 +536,9 @@
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="testSourceRemotely(req)">
|
||||
Test Android
|
||||
</v-btn>
|
||||
<v-btn @click="testSource(req)">
|
||||
Test
|
||||
</v-btn>
|
||||
@@ -497,7 +620,62 @@
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn>Clear</v-btn>
|
||||
<v-btn @click="Integration.logs = []">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-card style="margin: 20px;" v-if="Plugin.currentPlugin && Integration.httpExchanges">
|
||||
<v-card-title>
|
||||
Http Logs
|
||||
</v-card-title>
|
||||
</v-card-header>
|
||||
<v-card-text>
|
||||
<div style="position: absolute; top: 0px; right: 15px;">
|
||||
<v-checkbox v-model="Integration.showHttpRequests" label="Show Http Requests"></v-checkbox>
|
||||
</div>
|
||||
<div class="httpContainer" v-if="Integration.showHttpRequests">
|
||||
<div class="httpLine" v-for="exchange of Integration.httpExchanges">
|
||||
<div class="request" @click="toggleHttpExchange(exchange)">
|
||||
<div :class="[{ success: exchange.response.status < 300, warn: exchange.response.status >= 300 && exchange.response.status < 400, error: exchange.response.status >= 400 }, 'status']">
|
||||
{{exchange.response.status}}
|
||||
</div>
|
||||
<div class="method">
|
||||
{{exchange.request.method}}
|
||||
</div>
|
||||
<div class="url">
|
||||
{{exchange.request.url}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="response" v-if="exchange.response.show">
|
||||
<h2>Request Headers</h2>
|
||||
<div class="headers">
|
||||
<div class="header" v-for="(headerValue, header) in exchange.request.headers">
|
||||
<div class="key">
|
||||
{{header}}
|
||||
</div>
|
||||
<div class="value">
|
||||
{{headerValue}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Response</h2>
|
||||
<div class="headers">
|
||||
<div class="header" v-for="(headerValue, header) in exchange.response.headers">
|
||||
<div class="key">
|
||||
{{header}}
|
||||
</div>
|
||||
<div class="value">
|
||||
{{headerValue}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">{{exchange.response.body}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="Integration.showHttpRequests" @click="Integration.httpExchanges = []">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
@@ -535,6 +713,7 @@
|
||||
<!--<script src="./dependencies/vue.js"></script>-->
|
||||
<!--<script src="./dependencies/vuetify.js"></script>-->
|
||||
<script src="./source_docs.js"></script>
|
||||
<script src="./source_doc_urls.js"></script>
|
||||
<script src="./source.js"></script>
|
||||
<script src="./dev_bridge.js"></script>
|
||||
<script>
|
||||
@@ -545,6 +724,7 @@
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
searchTestMethods: "",
|
||||
page: "Plugin",
|
||||
pastPluginUrls: [],
|
||||
settings: {},
|
||||
@@ -552,7 +732,9 @@
|
||||
lastLogIndex: -1,
|
||||
lastLogDevID: "",
|
||||
logs: [],
|
||||
lastInjectTime: ""
|
||||
httpExchanges: [],
|
||||
lastInjectTime: "",
|
||||
showHttpRequests: false
|
||||
},
|
||||
Plugin: {
|
||||
loadUsingTag: false,
|
||||
@@ -570,6 +752,9 @@
|
||||
Testing: {
|
||||
requests: sourceDocs.map(x=>{
|
||||
x.parameters.forEach(y=>y.value = null);
|
||||
|
||||
if(sourceDocUrls[x.title])
|
||||
x.docUrl = sourceDocUrls[x.title];
|
||||
return x;
|
||||
}),
|
||||
lastResult: "",
|
||||
@@ -633,6 +818,16 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
if(this.Integration.showHttpRequests) {
|
||||
getDevHttpExchanges((exchanges)=>{
|
||||
Vue.nextTick(()=>{
|
||||
for(i = 0; i < exchanges.length; i++) {
|
||||
exchanges[i].response.show = false;
|
||||
this.Integration.httpExchanges.unshift(exchanges[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(ex) {
|
||||
console.error("Failed update", ex);
|
||||
@@ -674,6 +869,12 @@
|
||||
this.reloadPlugin();
|
||||
});
|
||||
},
|
||||
deletePastPlugin(url) {
|
||||
let currentPastPlugins = this.pastPluginUrls;
|
||||
currentPastPlugins = currentPastPlugins.filter(x=>x.toLowerCase() != url.toLowerCase());
|
||||
this.pastPluginUrls = currentPastPlugins;
|
||||
localStorage.setItem("pastPlugins", JSON.stringify(currentPastPlugins));
|
||||
},
|
||||
loginTestPlugin() {
|
||||
pluginLoginTestPlugin();
|
||||
setTimeout(()=>{
|
||||
@@ -860,8 +1061,58 @@
|
||||
"Error: " + ex;
|
||||
}
|
||||
},
|
||||
testSourceRemotely(req) {
|
||||
const name = req.title;
|
||||
const parameterVals = req.parameters.map(x=>{
|
||||
if(x.value && x.value.startsWith && x.value.startsWith("json:"))
|
||||
return JSON.parse(x.value.substring(5));
|
||||
return x.value
|
||||
});
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
else
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
if(!func)
|
||||
alert("Test func not found");
|
||||
|
||||
try {
|
||||
const remoteResult = pluginRemoteTest(name, parameterVals);
|
||||
console.log("Result for " + req.title, remoteResult);
|
||||
this.Testing.lastResult = "//Results [" + name + "]\n" +
|
||||
JSON.stringify(remoteResult, null, 3);
|
||||
this.Testing.lastResultError = "";
|
||||
}
|
||||
catch(ex) {
|
||||
if(ex.plugin_type == "CaptchaRequiredException") {
|
||||
let shouldCaptcha = confirm("Do you want to request captcha?");
|
||||
if(shouldCaptcha) {
|
||||
pluginCaptchaTestPlugin(ex.url, ex.body);
|
||||
}
|
||||
}
|
||||
console.error("Failed to run test for " + req.title, ex);
|
||||
this.Testing.lastResult = ""
|
||||
if(ex.message)
|
||||
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
|
||||
"Error: " + ex.message + "\n\n" + ex.stack;
|
||||
else
|
||||
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
|
||||
"Error: " + ex;
|
||||
}
|
||||
},
|
||||
showTestResults(results) {
|
||||
|
||||
},
|
||||
toggleHttpExchange(exchange) {
|
||||
exchange.response.show = !exchange.response.show;
|
||||
},
|
||||
copyClipboard(cpy) {
|
||||
if(navigator.clipboard)
|
||||
|
||||
+120
-46
@@ -1,13 +1,37 @@
|
||||
|
||||
declare class ScriptException extends Error {
|
||||
//If only one parameter is provided, acts as msg
|
||||
constructor(type: string, msg: string);
|
||||
}
|
||||
declare class TimeoutException extends ScriptException {
|
||||
|
||||
declare class LoginRequiredException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
//Alias
|
||||
declare class ScriptLoginRequiredException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class CaptchaRequiredException extends ScriptException {
|
||||
constructor(url: string, body: string);
|
||||
}
|
||||
|
||||
declare class CriticalException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class UnavailableException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class AgeException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class TimeoutException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class ScriptImplementationException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
@@ -38,16 +62,23 @@ declare class FilterCapability {
|
||||
|
||||
|
||||
declare class PlatformAuthorLink {
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?);
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
|
||||
}
|
||||
|
||||
declare class PlatformAuthorMembershipLink {
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
|
||||
}
|
||||
|
||||
declare interface PlatformContentDef {
|
||||
id: PlatformID,
|
||||
name: string,
|
||||
thumbnails: Thumbnails,
|
||||
author: PlatformAuthorLink,
|
||||
datetime: integer,
|
||||
url: string
|
||||
}
|
||||
declare interface PlatformContent {}
|
||||
|
||||
declare interface PlatformNestedMediaContentDef extends PlatformContentDef {
|
||||
contentUrl: string,
|
||||
contentName: string?,
|
||||
@@ -59,16 +90,26 @@ declare class PlatformNestedMediaContent {
|
||||
constructor(obj: PlatformNestedMediaContentDef);
|
||||
}
|
||||
|
||||
declare interface PlatformLockedContentDef extends PlatformContentDef {
|
||||
contentName: string?,
|
||||
contentThumbnails: Thumbnails?,
|
||||
unlockUrl: string,
|
||||
lockDescription: string?,
|
||||
}
|
||||
declare class PlatformLockedContent {
|
||||
constructor(obj: PlatformLockedContentDef);
|
||||
}
|
||||
|
||||
|
||||
declare interface PlatformVideoDef extends PlatformContentDef {
|
||||
thumbnails: Thumbnails,
|
||||
author: PlatformAuthorLink,
|
||||
|
||||
duration: int,
|
||||
viewCount: long,
|
||||
isLive: boolean
|
||||
isLive: boolean,
|
||||
shareUrl: string?
|
||||
}
|
||||
declare interface PlatformContent {}
|
||||
|
||||
declare class PlatformVideo implements PlatformContent {
|
||||
constructor(obj: PlatformVideoDef);
|
||||
}
|
||||
@@ -77,14 +118,15 @@ declare class PlatformVideo implements PlatformContent {
|
||||
declare interface PlatformVideoDetailsDef extends PlatformVideoDef {
|
||||
description: string,
|
||||
video: VideoSourceDescriptor,
|
||||
live: SubtitleSource[],
|
||||
rating: IRating
|
||||
live: IVideoSource,
|
||||
rating: IRating,
|
||||
subtitles: SubtitleSource[]
|
||||
}
|
||||
declare class PlatformVideoDetails extends PlatformVideo {
|
||||
constructor(obj: PlatformVideoDetailsDef);
|
||||
}
|
||||
|
||||
declare class PlatformPostDef extends PlatformContentDef {
|
||||
declare interface PlatformPostDef extends PlatformContentDef {
|
||||
thumbnails: string[],
|
||||
images: string[],
|
||||
description: string
|
||||
@@ -93,7 +135,7 @@ declare class PlatformPost extends PlatformContent {
|
||||
constructor(obj: PlatformPostDef)
|
||||
}
|
||||
|
||||
declare class PlatformPostDetailsDef extends PlatformPostDef {
|
||||
declare interface PlatformPostDetailsDef extends PlatformPostDef {
|
||||
rating: IRating,
|
||||
textType: int,
|
||||
content: String
|
||||
@@ -110,8 +152,8 @@ declare interface MuxVideoSourceDescriptorDef {
|
||||
isUnMuxed: boolean,
|
||||
videoSources: VideoSource[]
|
||||
}
|
||||
declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor {
|
||||
constructor(obj: VideoSourceDescriptorDef);
|
||||
declare class VideoSourceDescriptor implements IVideoSourceDescriptor {
|
||||
constructor(videoSourcesOrObj: VideoSource[]);
|
||||
}
|
||||
|
||||
declare interface UnMuxVideoSourceDescriptorDef {
|
||||
@@ -129,7 +171,7 @@ declare interface IVideoSource {
|
||||
declare interface IAudioSource {
|
||||
|
||||
}
|
||||
interface VideoUrlSourceDef implements IVideoSource {
|
||||
declare interface VideoUrlSourceDef implements IVideoSource {
|
||||
width: integer,
|
||||
height: integer,
|
||||
container: string,
|
||||
@@ -139,22 +181,22 @@ interface VideoUrlSourceDef implements IVideoSource {
|
||||
duration: integer,
|
||||
url: string
|
||||
}
|
||||
class VideoUrlSource {
|
||||
declare class VideoUrlSource {
|
||||
constructor(obj: VideoUrlSourceDef);
|
||||
|
||||
getRequestModifier(): RequestModifier?;
|
||||
}
|
||||
interface VideoUrlRangeSourceDef extends VideoUrlSource {
|
||||
declare interface VideoUrlRangeSourceDef extends VideoUrlSource {
|
||||
itagId: integer,
|
||||
initStart: integer,
|
||||
initEnd: integer,
|
||||
indexStart: integer,
|
||||
indexEnd: integer,
|
||||
}
|
||||
class VideoUrlRangeSource extends VideoUrlSource {
|
||||
declare class VideoUrlRangeSource extends VideoUrlSource {
|
||||
constructor(obj: YTVideoSourceDef);
|
||||
}
|
||||
interface AudioUrlSourceDef {
|
||||
declare interface AudioUrlSourceDef {
|
||||
name: string,
|
||||
bitrate: integer,
|
||||
container: string,
|
||||
@@ -163,24 +205,12 @@ interface AudioUrlSourceDef {
|
||||
url: string,
|
||||
language: string
|
||||
}
|
||||
class AudioUrlSource implements IAudioSource {
|
||||
declare class AudioUrlSource implements IAudioSource {
|
||||
constructor(obj: AudioUrlSourceDef);
|
||||
|
||||
getRequestModifier(): RequestModifier?;
|
||||
}
|
||||
interface IRequest {
|
||||
url: string,
|
||||
headers: Map<string, string>
|
||||
}
|
||||
interface IRequestModifierDef {
|
||||
allowByteSkip: boolean
|
||||
}
|
||||
class RequestModifier {
|
||||
constructor(obj: IRequestModifierDef) { }
|
||||
|
||||
modifyRequest(url: string, headers: Map<string, string>): IRequest;
|
||||
}
|
||||
interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
declare interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
itagId: integer,
|
||||
initStart: integer,
|
||||
initEnd: integer,
|
||||
@@ -188,28 +218,44 @@ interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
indexEnd: integer,
|
||||
audioChannels: integer
|
||||
}
|
||||
class AudioUrlRangeSource extends AudioUrlSource {
|
||||
declare class AudioUrlRangeSource extends AudioUrlSource {
|
||||
constructor(obj: AudioUrlRangeSourceDef);
|
||||
}
|
||||
interface HLSSourceDef {
|
||||
declare interface HLSSourceDef {
|
||||
name: string,
|
||||
duration: integer,
|
||||
url: string
|
||||
url: string,
|
||||
priority: boolean?,
|
||||
language: string?
|
||||
}
|
||||
class HLSSource implements IVideoSource {
|
||||
declare class HLSSource implements IVideoSource {
|
||||
constructor(obj: HLSSourceDef);
|
||||
}
|
||||
interface DashSourceDef {
|
||||
declare interface DashSourceDef {
|
||||
name: string,
|
||||
duration: integer,
|
||||
url: string
|
||||
url: string,
|
||||
language: string?
|
||||
}
|
||||
class DashSource implements IVideoSource {
|
||||
declare class DashSource implements IVideoSource {
|
||||
constructor(obj: DashSourceDef)
|
||||
}
|
||||
|
||||
declare interface IRequest {
|
||||
url: string,
|
||||
headers: Map<string, string>
|
||||
}
|
||||
declare interface IRequestModifierDef {
|
||||
allowByteSkip: boolean
|
||||
}
|
||||
declare class RequestModifier {
|
||||
constructor(obj: IRequestModifierDef) { }
|
||||
|
||||
modifyRequest(url: string, headers: Map<string, string>): IRequest;
|
||||
}
|
||||
|
||||
//Channel
|
||||
interface PlatformChannelDef {
|
||||
declare interface PlatformChannelDef {
|
||||
id: PlatformID,
|
||||
name: string,
|
||||
thumbnail: string,
|
||||
@@ -217,12 +263,29 @@ interface PlatformChannelDef {
|
||||
subscribers: integer,
|
||||
description: string,
|
||||
url: string,
|
||||
urlAlternatives: string[],
|
||||
links: Map<string>?
|
||||
}
|
||||
class PlatformChannel {
|
||||
declare class PlatformChannel {
|
||||
constructor(obj: PlatformChannelDef);
|
||||
}
|
||||
|
||||
//Playlist
|
||||
declare interface PlatformPlaylistDef implements PlatformContent {
|
||||
videoCount: integer,
|
||||
thumbnail: string
|
||||
}
|
||||
declare class PlatformPlaylist extends PlatformContent {
|
||||
constructor(obj: PlatformPlaylistDef);
|
||||
}
|
||||
declare interface PlatformPlaylistDetailsDef implements PlatformPlaylistDef {
|
||||
contents: ContentPager
|
||||
}
|
||||
declare class PlatformPlaylistDetails extends PlatformContent {
|
||||
constructor(obj: PlatformPlaylistDetailsDef);
|
||||
}
|
||||
|
||||
|
||||
//Ratings
|
||||
interface IRating {
|
||||
type: integer
|
||||
@@ -250,7 +313,11 @@ declare class PlatformComment {
|
||||
constructor(obj: CommentDef);
|
||||
}
|
||||
|
||||
declare class PlaybackTracker {
|
||||
constructor(interval: integer);
|
||||
|
||||
setProgress(seconds: integer);
|
||||
}
|
||||
|
||||
declare class LiveEventPager {
|
||||
nextRequest = 4000;
|
||||
@@ -261,8 +328,8 @@ declare class LiveEventPager {
|
||||
nextPage(): LiveEventPager; //Could be self
|
||||
}
|
||||
|
||||
class LiveEvent {
|
||||
type: String
|
||||
declare class LiveEvent {
|
||||
constructor(type: integer);
|
||||
}
|
||||
declare class LiveEventComment extends LiveEvent {
|
||||
constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]);
|
||||
@@ -287,25 +354,31 @@ declare class ContentPager {
|
||||
constructor(results: PlatformContent[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): VideoPager; //Could be self
|
||||
nextPage(): ContentPager?; //Could be self
|
||||
}
|
||||
declare class VideoPager {
|
||||
constructor(results: PlatformVideo[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): VideoPager; //Could be self
|
||||
nextPage(): VideoPager?; //Could be self
|
||||
}
|
||||
declare class ChannelPager {
|
||||
constructor(results: PlatformChannel[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean;
|
||||
nextPage(): ChannelPager; //Could be self
|
||||
nextPage(): ChannelPager?; //Could be self
|
||||
}
|
||||
declare class PlaylistPager {
|
||||
constructor(results: PlatformPlaylist[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean;
|
||||
nextPage(): PlaylistPager?;
|
||||
}
|
||||
declare class CommentPager {
|
||||
constructor(results: PlatformComment[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): CommentPager; //Could be self
|
||||
nextPage(): CommentPager?; //Could be self
|
||||
}
|
||||
|
||||
interface Map<T> {
|
||||
@@ -341,8 +414,9 @@ interface Source {
|
||||
getChannelCapabilities(): ResultCapabilities;
|
||||
|
||||
isContentDetailsUrl(url: string): boolean;
|
||||
getContentDetails(url: string): PlatformVideoDetails;
|
||||
getContentDetails(url: string): PlatformContentDetails;
|
||||
|
||||
//Optional
|
||||
getLiveEvents(url: string): LiveEventPager;
|
||||
|
||||
//Optional
|
||||
|
||||
@@ -37,24 +37,26 @@ let Type = {
|
||||
NORMAL: 0,
|
||||
|
||||
SKIPPABLE: 5,
|
||||
SKIP: 6
|
||||
SKIP: 6,
|
||||
SKIPONCE: 7
|
||||
}
|
||||
};
|
||||
|
||||
let Language = {
|
||||
UNKNOWN: "Unknown",
|
||||
ARABIC: "Arabic",
|
||||
SPANISH: "Spanish",
|
||||
FRENCH: "French",
|
||||
HINDI: "Hindi",
|
||||
INDONESIAN: "Indonesian",
|
||||
KOREAN: "Korean",
|
||||
PORTBRAZIL: "Portuguese Brazilian",
|
||||
RUSSIAN: "Russian",
|
||||
THAI: "Thai",
|
||||
TURKISH: "Turkish",
|
||||
VIETNAMESE: "Vietnamese",
|
||||
ENGLISH: "English"
|
||||
ARABIC: "ar",
|
||||
SPANISH: "es",
|
||||
FRENCH: "fr",
|
||||
HINDI: "hi",
|
||||
INDONESIAN: "id",
|
||||
KOREAN: "ko",
|
||||
PORTUGUESE: "pt",
|
||||
PORTBRAZIL: "pt",
|
||||
RUSSIAN: "ru",
|
||||
THAI: "th",
|
||||
TURKISH: "tr",
|
||||
VIETNAMESE: "vi",
|
||||
ENGLISH: "en"
|
||||
}
|
||||
|
||||
class ScriptException extends Error {
|
||||
@@ -76,6 +78,11 @@ class ScriptLoginRequiredException extends ScriptException {
|
||||
super("ScriptLoginRequiredException", msg);
|
||||
}
|
||||
}
|
||||
class LoginRequiredException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("ScriptLoginRequiredException", msg);
|
||||
}
|
||||
}
|
||||
class CaptchaRequiredException extends Error {
|
||||
constructor(url, body) {
|
||||
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
|
||||
@@ -247,8 +254,8 @@ class PlatformVideoDetails extends PlatformVideo {
|
||||
|
||||
this.description = obj.description ?? "";//String
|
||||
this.video = obj.video ?? {}; //VideoSourceDescriptor
|
||||
this.dash = obj.dash ?? null; //DashSource
|
||||
this.hls = obj.hls ?? null; //HLSSource
|
||||
this.dash = obj.dash ?? null; //DashSource, deprecated
|
||||
this.hls = obj.hls ?? null; //HLSSource, deprecated
|
||||
this.live = obj.live ?? null; //VideoSource
|
||||
|
||||
this.rating = obj.rating ?? null; //IRating
|
||||
@@ -319,6 +326,8 @@ class VideoUrlSource {
|
||||
this.bitrate = obj.bitrate ?? 0;
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class VideoUrlRangeSource extends VideoUrlSource {
|
||||
@@ -344,6 +353,17 @@ class AudioUrlSource {
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class AudioUrlWidevineSource extends AudioUrlSource {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
this.plugin_type = "AudioUrlWidevineSource";
|
||||
|
||||
this.bearerToken = obj.bearerToken;
|
||||
this.licenseUri = obj.licenseUri;
|
||||
}
|
||||
}
|
||||
class AudioUrlRangeSource extends AudioUrlSource {
|
||||
@@ -369,6 +389,8 @@ class HLSSource {
|
||||
this.priority = obj.priority ?? false;
|
||||
if(obj.language)
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class DashSource {
|
||||
@@ -380,13 +402,15 @@ class DashSource {
|
||||
this.url = obj.url;
|
||||
if(obj.language)
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
|
||||
class RequestModifier {
|
||||
constructor(obj) {
|
||||
obj = obj ?? {};
|
||||
this.allowByteSkip = obj.allowByteSkip;
|
||||
this.allowByteSkip = obj.allowByteSkip; //Kinda deprecated.. wip
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
|
||||
fun IPlatformVideoDetails.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
||||
fun IVideoSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
||||
fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
||||
|
||||
@UnstableApi
|
||||
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||
val requestModifier = getRequestModifier();
|
||||
return if (requestModifier != null) {
|
||||
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory();
|
||||
}
|
||||
}
|
||||
|
||||
fun IVideoSourceDescriptor.hasAnySource(): Boolean = this.videoSources.any() || (this is VideoUnMuxedSourceDescriptor && this.audioSources.any());
|
||||
@@ -13,7 +13,8 @@ import java.text.DecimalFormat
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.toDuration
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
//Long
|
||||
@@ -120,7 +121,8 @@ fun OffsetDateTime.getNowDiffMonths(): Long {
|
||||
return ChronoUnit.MONTHS.between(this, OffsetDateTime.now());
|
||||
}
|
||||
fun OffsetDateTime.getNowDiffYears(): Long {
|
||||
return ChronoUnit.YEARS.between(this, OffsetDateTime.now());
|
||||
val diff = ChronoUnit.MONTHS.between(this, OffsetDateTime.now()) / 12.0;
|
||||
return diff.roundToLong();
|
||||
}
|
||||
|
||||
fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long {
|
||||
@@ -151,6 +153,7 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
|
||||
if(value >= secondsInYear) {
|
||||
value = getNowDiffYears();
|
||||
if(abs) value = abs(value);
|
||||
value = Math.max(1, value);
|
||||
unit = "year";
|
||||
}
|
||||
else if(value >= secondsInMonth) {
|
||||
@@ -228,6 +231,18 @@ fun String.fixHtmlWhitespace(): Spanned {
|
||||
return Html.fromHtml(replace("\n", "<br />"), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
}
|
||||
|
||||
fun Long.formatDuration(): String {
|
||||
val hours = this / 3600000
|
||||
val minutes = (this % 3600000) / 60000
|
||||
val seconds = (this % 60000) / 1000
|
||||
|
||||
return if (hours > 0) {
|
||||
String.format("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
String.format("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.fixHtmlLinks(): Spanned {
|
||||
//TODO: Properly fix whitespace handling.
|
||||
val doc = Jsoup.parse(replace("\n", "<br />"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.util.Log
|
||||
import com.google.common.base.CharMatcher
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
@@ -9,7 +10,6 @@ import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
private const val IPV4_PART_COUNT = 4;
|
||||
@@ -169,7 +169,7 @@ private fun parseHextet(ipString: String, start: Int, end: Int): Short {
|
||||
var hextet = 0
|
||||
for (i in start until end) {
|
||||
hextet = hextet shl 4
|
||||
hextet = hextet or ipString[i].digitToIntOrNull(16)!! ?: -1
|
||||
hextet = hextet or ipString[i].digitToIntOrNull(16)!!
|
||||
}
|
||||
return hextet.toShort()
|
||||
}
|
||||
@@ -216,15 +216,20 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
||||
}
|
||||
|
||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
val timeout = 2000
|
||||
|
||||
if (addresses.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (addresses.size == 1) {
|
||||
val socket = Socket()
|
||||
|
||||
try {
|
||||
return Socket(addresses[0], port);
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
||||
} catch (e: Throwable) {
|
||||
//Ignored.
|
||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||
socket.close()
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -249,7 +254,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
}
|
||||
}
|
||||
|
||||
socket.connect(InetSocketAddress(address, port));
|
||||
socket.connect(InetSocketAddress(address, port), timeout);
|
||||
|
||||
synchronized(syncObject) {
|
||||
if (connectedSocket == null) {
|
||||
@@ -263,7 +268,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
//Ignore
|
||||
Log.i("getConnectedSocket", "Failed to connect to: $address", e)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.adapters.CommentViewHolder
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import userpackage.Protocol
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
@@ -47,6 +49,12 @@ fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||
}
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
||||
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
|
||||
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
|
||||
addServer(PolycentricCache.SERVER)
|
||||
}
|
||||
|
||||
val exceptions = fullyBackfillServers()
|
||||
for (pair in exceptions) {
|
||||
val server = pair.key
|
||||
|
||||
@@ -27,7 +27,7 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
|
||||
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}");
|
||||
return this as T;
|
||||
return this;
|
||||
}
|
||||
|
||||
//Singles
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
class PresetImages {
|
||||
companion object {
|
||||
val images = mapOf<String, Int>(
|
||||
Pair("xp_book", R.drawable.xp_book),
|
||||
Pair("xp_forest", R.drawable.xp_forest),
|
||||
Pair("xp_code", R.drawable.xp_code),
|
||||
Pair("xp_controller", R.drawable.xp_controller),
|
||||
Pair("xp_laptop", R.drawable.xp_laptop)
|
||||
);
|
||||
|
||||
fun getPresetResIdByName(name: String): Int {
|
||||
return images[name] ?: -1;
|
||||
}
|
||||
fun getPresetNameByResId(id: Int): String? {
|
||||
return images.entries.find { it.value == id }?.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,41 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.activities.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -248,20 +261,23 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
||||
var showSubscriptionGroups: Boolean = true;
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var fetchOnAppBoot: Boolean = true;
|
||||
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10, "background_update")
|
||||
@DropdownFieldOptionsId(R.array.background_interval)
|
||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||
|
||||
@@ -277,7 +293,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
|
||||
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 11)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var subscriptionConcurrency: Int = 3;
|
||||
|
||||
@@ -285,17 +301,20 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return threadIndexToCount(subscriptionConcurrency);
|
||||
}
|
||||
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
|
||||
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
||||
var peekChannelContents: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
StateCache.instance.clear();
|
||||
@@ -311,7 +330,28 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
fun getPrimaryLanguage(context: Context): String? {
|
||||
return when(primaryLanguage) {
|
||||
0 -> "en";
|
||||
1 -> "es";
|
||||
2 -> "de";
|
||||
3 -> "fr";
|
||||
4 -> "ja";
|
||||
5 -> "ko";
|
||||
6 -> "th";
|
||||
7 -> "vi";
|
||||
8 -> "id";
|
||||
9 -> "hi";
|
||||
10 -> "ar";
|
||||
11 -> "tu";
|
||||
12 -> "ru";
|
||||
13 -> "pt";
|
||||
14 -> "zh";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
|
||||
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||
@@ -407,6 +447,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12)
|
||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||
var restartPlaybackAfterConnectivityLoss: Int = 1;
|
||||
|
||||
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
||||
var fullscreenPortrait: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -506,6 +549,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
fun isVerbose() = logLevel >= 4;
|
||||
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
@@ -645,7 +690,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateUpdate.instance.checkForUpdates(it, true);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(it, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -767,7 +814,36 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var polycentricEnabled: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||
var gestureControls = GestureControls();
|
||||
@Serializable
|
||||
class GestureControls {
|
||||
@FormField(R.string.volume_slider, FieldForm.TOGGLE, R.string.volume_slider_descr, 1)
|
||||
var volumeSlider: Boolean = true;
|
||||
|
||||
@FormField(R.string.brightness_slider, FieldForm.TOGGLE, R.string.brightness_slider_descr, 2)
|
||||
var brightnessSlider: Boolean = true;
|
||||
|
||||
@FormField(R.string.toggle_full_screen, FieldForm.TOGGLE, R.string.toggle_full_screen_descr, 3)
|
||||
var toggleFullscreen: Boolean = true;
|
||||
|
||||
@FormField(R.string.system_brightness, FieldForm.TOGGLE, R.string.system_brightness_descr, 4)
|
||||
var useSystemBrightness: Boolean = false;
|
||||
|
||||
@FormField(R.string.system_volume, FieldForm.TOGGLE, R.string.system_volume_descr, 5)
|
||||
var useSystemVolume: Boolean = true;
|
||||
|
||||
@FormField(R.string.restore_system_brightness, FieldForm.TOGGLE, R.string.restore_system_brightness_descr, 6)
|
||||
var restoreSystemBrightness: Boolean = true;
|
||||
|
||||
@FormField(R.string.zoom_option, FieldForm.TOGGLE, R.string.zoom_option_descr, 7)
|
||||
var zoom: Boolean = true;
|
||||
|
||||
@FormField(R.string.pan_option, FieldForm.TOGGLE, R.string.pan_option_descr, 8)
|
||||
var pan: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
||||
@@ -2,15 +2,9 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.DeveloperActivity
|
||||
@@ -36,7 +30,6 @@ import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||
import com.futo.platformplayer.views.fields.ButtonField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
@@ -44,11 +37,12 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@@ -109,14 +103,14 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val subsCache =
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(cacheScope = this)?.first;
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(cacheScope = this).first;
|
||||
|
||||
var total = 0;
|
||||
var page = 0;
|
||||
var lastToast = System.currentTimeMillis();
|
||||
while(subsCache!!.hasMorePages() && total < 5000) {
|
||||
subsCache!!.nextPage();
|
||||
total += subsCache!!.getResults().size;
|
||||
while(subsCache.hasMorePages() && total < 5000) {
|
||||
subsCache.nextPage();
|
||||
total += subsCache.getResults().size;
|
||||
page++;
|
||||
|
||||
if(page % 10 == 0)
|
||||
@@ -174,9 +168,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
var total = 0;
|
||||
var page = 0;
|
||||
var lastToast = System.currentTimeMillis();
|
||||
while(subsCache!!.hasMorePages() && total < 5000) {
|
||||
subsCache!!.nextPage();
|
||||
total += subsCache!!.getResults().size;
|
||||
while(subsCache.hasMorePages() && total < 5000) {
|
||||
subsCache.nextPage();
|
||||
total += subsCache.getResults().size;
|
||||
page++;
|
||||
|
||||
for(item in subsCache.getResults().filterIsInstance<IPlatformVideo>()) {
|
||||
@@ -375,9 +369,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.getHome, FieldForm.BUTTON, R.string.attempts_to_fetch_2_pages_from_getHome, 2)
|
||||
fun testV8Home() {
|
||||
runTestPlugin(_currentPlugin) {
|
||||
var home: IPager<IPlatformContent>? = null;
|
||||
var resultPage1: String = "";
|
||||
var resultPage2: String = "";
|
||||
var home: IPager<IPlatformContent>?;
|
||||
val resultPage1: String;
|
||||
val resultPage2: String;
|
||||
val page1Time = measureTimeMillis {
|
||||
home = it.getHome();
|
||||
val results = home!!.getResults();
|
||||
|
||||
@@ -11,17 +11,40 @@ import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.*
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.dialogs.*
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.AutomaticBackupDialog
|
||||
import com.futo.platformplayer.dialogs.AutomaticRestoreDialog
|
||||
import com.futo.platformplayer.dialogs.CastingAddDialog
|
||||
import com.futo.platformplayer.dialogs.CastingHelpDialog
|
||||
import com.futo.platformplayer.dialogs.ChangelogDialog
|
||||
import com.futo.platformplayer.dialogs.CommentDialog
|
||||
import com.futo.platformplayer.dialogs.ConnectCastingDialog
|
||||
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.PluginUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.ProgressDialog
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -167,6 +190,14 @@ class UIDialogs {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showPluginUpdateDialog(context: Context, oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig) {
|
||||
val dialog = PluginUpdateDialog(context, oldConfig, newConfig);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
@@ -252,22 +283,48 @@ class UIDialogs {
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
|
||||
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null, mainFragment: MainFragment? = null) {
|
||||
val pluginConfig = if(ex is PluginException) ex.config else null;
|
||||
val pluginInfo = if(ex is PluginException)
|
||||
"\nPlugin [${ex.config.name}]" else "";
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
(if(ex != null ) "${ex.message}" else ""),
|
||||
if(ex is PluginException) ex.code else null,
|
||||
0,
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE)
|
||||
);
|
||||
|
||||
var exMsg = if(ex != null ) "${ex.message}" else "";
|
||||
if(pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
|
||||
exMsg += "\n\nAn update is available"
|
||||
|
||||
if(mainFragment != null && pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
exMsg,
|
||||
if(ex is PluginException) ex.code else null,
|
||||
1,
|
||||
UIDialogs.Action(context.getString(R.string.update), {
|
||||
mainFragment.navigate<SourceDetailFragment>(SourceDetailFragment.UpdatePluginAction(pluginConfig));
|
||||
if(mainFragment is VideoDetailFragment)
|
||||
mainFragment.minimizeVideoDetail();
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
else
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
exMsg,
|
||||
if(ex is PluginException) ex.code else null,
|
||||
0,
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
|
||||
fun showSingleButtonDialog(context: Context, icon: Int, text: String, buttonText: String, action: (() -> Unit)) {
|
||||
@@ -288,12 +345,16 @@ class UIDialogs {
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int) {
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
val dialog = AutoUpdateDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
dialog.setMaxVersion(lastVersion);
|
||||
|
||||
if (hideExceptionButtons) {
|
||||
dialog.hideExceptionButtons()
|
||||
}
|
||||
}
|
||||
|
||||
fun showChangelogDialog(context: Context, lastVersion: Int) {
|
||||
@@ -323,8 +384,8 @@ class UIDialogs {
|
||||
}
|
||||
}
|
||||
|
||||
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, onConcluded: () -> Unit) {
|
||||
val dialog = ImportDialog(context, store, name, reconstructions, onConcluded);
|
||||
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, cache: ImportCache?, onConcluded: () -> Unit) {
|
||||
val dialog = ImportDialog(context, store, name, reconstructions, cache, onConcluded);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
@@ -341,11 +402,17 @@ class UIDialogs {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null) {
|
||||
val dialog = ConnectedCastingDialog(context);
|
||||
if (context is Activity) {
|
||||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
val dialog = ConnectCastingDialog(context);
|
||||
if (context is Activity) {
|
||||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
val c = context
|
||||
if (c is Activity) {
|
||||
@@ -377,13 +444,28 @@ class UIDialogs {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
StateApp.withContext {
|
||||
Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show();
|
||||
toast(it, text, long);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show toast.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun appToast(text: String, long: Boolean = false) {
|
||||
appToast(ToastView.Toast(text, long))
|
||||
}
|
||||
fun appToastError(text: String, long: Boolean) {
|
||||
StateApp.withContext {
|
||||
appToast(ToastView.Toast(text, long, it.getColor(R.color.pastel_red)));
|
||||
};
|
||||
}
|
||||
fun appToast(toast: ToastView.Toast) {
|
||||
StateApp.withContext {
|
||||
if(it is MainActivity) {
|
||||
it.showAppToast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) {
|
||||
//TODO: Is not actually clickable...
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
@@ -17,31 +24,46 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuRecycler
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.*
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
private const val TAG = "UISlideOverlays";
|
||||
|
||||
fun showOverlay(container: ViewGroup, title: String, okButton: String?, onOk: ()->Unit, vararg views: View) {
|
||||
fun showOverlay(container: ViewGroup, title: String, okButton: String?, onOk: ()->Unit, vararg views: View): SlideUpMenuOverlay {
|
||||
var menu = SlideUpMenuOverlay(container.context, container, title, okButton, true, *views);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
@@ -49,9 +71,10 @@ class UISlideOverlays {
|
||||
onOk.invoke();
|
||||
};
|
||||
menu.show();
|
||||
return menu;
|
||||
}
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
@@ -60,19 +83,48 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
@@ -91,9 +143,17 @@ class UISlideOverlays {
|
||||
}, false) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
}, false) else null).filterNotNull());
|
||||
}, false) else null/*,,
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
-1, listOf())
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||
showCreateSubscriptionGroup(container, subscription.channel);
|
||||
}, false)*/
|
||||
).filterNotNull());
|
||||
|
||||
menu.setItems(items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
@@ -110,8 +170,29 @@ class UISlideOverlays {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
|
||||
if(subscription.doNotifications && !originalNotif && Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
if(subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
|
||||
if(mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
return@subscribe;
|
||||
}
|
||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
||||
if(mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
@@ -127,6 +208,12 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
fun showAddToGroupOverlay(channel: IPlatformVideo, container: ViewGroup) {
|
||||
|
||||
}
|
||||
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
@@ -242,7 +329,7 @@ class UISlideOverlays {
|
||||
val audioSources = if(descriptor is VideoUnMuxedSourceDescriptor) descriptor.audioSources else null;
|
||||
val subtitleSources = video.subtitles;
|
||||
|
||||
if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) {
|
||||
if(videoSources.isEmpty() && (audioSources?.size ?: 0) == 0) {
|
||||
UIDialogs.toast(container.context.getString(R.string.no_downloads_available), false);
|
||||
return null;
|
||||
}
|
||||
@@ -263,56 +350,62 @@ class UISlideOverlays {
|
||||
videoSources
|
||||
.filter { it.isDownloadable() }
|
||||
.map {
|
||||
if (it is IVideoUrlSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
} else if (it is IHLSManifestSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
} else {
|
||||
throw Exception("Unhandled source type")
|
||||
when (it) {
|
||||
is IVideoUrlSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unhandled source type")
|
||||
}
|
||||
}
|
||||
}).flatten().toList()
|
||||
));
|
||||
|
||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
|
||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.isNotEmpty()) {
|
||||
//TODO: Add HLS support here
|
||||
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||
) as IVideoUrlSource;
|
||||
) as IVideoUrlSource?;
|
||||
}
|
||||
|
||||
audioSources?.let { audioSources ->
|
||||
if (audioSources != null) {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
|
||||
.filter { VideoHelper.isDownloadable(it) }
|
||||
.map {
|
||||
if (it is IAudioUrlSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false);
|
||||
} else if (it is IHLSManifestAudioSource) {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
} else {
|
||||
throw Exception("Unhandled source type")
|
||||
when (it) {
|
||||
is IAudioUrlSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false);
|
||||
}
|
||||
|
||||
is IHLSManifestAudioSource -> {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
}, false)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unhandled source type")
|
||||
}
|
||||
}
|
||||
}));
|
||||
val asources = audioSources;
|
||||
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
||||
menu?.selectOption(asources, preferredAudioSource);
|
||||
|
||||
//TODO: Add HLS support here
|
||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
|
||||
@@ -321,10 +414,8 @@ class UISlideOverlays {
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||
}
|
||||
|
||||
//ContentResolver is required for subtitles..
|
||||
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
|
||||
.map {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||
if (selectedSubtitle == it) {
|
||||
selectedSubtitle = null;
|
||||
@@ -334,7 +425,8 @@ class UISlideOverlays {
|
||||
menu?.selectOption(subtitleSources, it);
|
||||
}
|
||||
}, false);
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items);
|
||||
@@ -418,10 +510,15 @@ class UISlideOverlays {
|
||||
}
|
||||
}
|
||||
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
|
||||
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
|
||||
showUnknownVideoDownload(container.context.getString(R.string.playlist), container) { px, bitrate ->
|
||||
StateDownloads.instance.download(playlist, px, bitrate);
|
||||
};
|
||||
}
|
||||
fun showDownloadWatchlaterOverlay(container: ViewGroup) {
|
||||
showUnknownVideoDownload(container.context.getString(R.string.watch_later), container, { px, bitrate ->
|
||||
StateDownloads.instance.downloadWatchLater(px, bitrate);
|
||||
})
|
||||
}
|
||||
private fun showUnknownVideoDownload(toDownload: String, container: ViewGroup, cb: (Long?, Long?)->Unit) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
@@ -502,6 +599,48 @@ class UISlideOverlays {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
fun showCreateSubscriptionGroup(container: ViewGroup, initialChannel: IPlatformChannel? = null, onCreate: ((String) -> Unit)? = null): SlideUpMenuOverlay {
|
||||
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
|
||||
val addSubGroupOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_subgroup), container.context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
addSubGroupOverlay.onOK.subscribe {
|
||||
val text = nameInput.text;
|
||||
if (text.isBlank()) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
addSubGroupOverlay.hide();
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
if(onCreate == null)
|
||||
{
|
||||
//TODO: Do this better, temp
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if(it is MainActivity) {
|
||||
val subGroup = SubscriptionGroup(text);
|
||||
if(initialChannel != null) {
|
||||
subGroup.urls.add(initialChannel.url);
|
||||
if(initialChannel.thumbnail != null)
|
||||
subGroup.image = ImageVariable(initialChannel.thumbnail);
|
||||
}
|
||||
it.navigate(it.getFragment<SubscriptionGroupFragment>(), subGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
onCreate(text)
|
||||
};
|
||||
|
||||
addSubGroupOverlay.onCancel.subscribe {
|
||||
nameInput.deactivate();
|
||||
nameInput.clear();
|
||||
};
|
||||
|
||||
addSubGroupOverlay.show();
|
||||
nameInput.activate();
|
||||
|
||||
return addSubGroupOverlay
|
||||
}
|
||||
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
|
||||
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
|
||||
@@ -549,9 +688,17 @@ class UISlideOverlays {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
|
||||
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
|
||||
container.context.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, url);
|
||||
type = "text/plain";
|
||||
}, null));
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
@@ -589,7 +736,7 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
|
||||
fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup): SlideUpMenuOverlay {
|
||||
fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup, slideUpMenuOverlayUpdated: (SlideUpMenuOverlay) -> Unit): SlideUpMenuOverlay {
|
||||
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
@@ -620,6 +767,13 @@ class UISlideOverlays {
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
});
|
||||
}, false))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
@@ -634,21 +788,22 @@ class UISlideOverlays {
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
||||
|
||||
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
|
||||
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), invokeParents: Boolean = true, onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
|
||||
val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
|
||||
val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
|
||||
|
||||
val views = arrayOf(hidden
|
||||
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
|
||||
btn.handler?.invoke(btn);
|
||||
}, true) as View }.toTypedArray() ?: arrayOf(),
|
||||
val views = arrayOf(
|
||||
hidden
|
||||
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
|
||||
btn.handler?.invoke(btn);
|
||||
}, invokeParents) as View }.toTypedArray(),
|
||||
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
val selected = it
|
||||
|
||||
@@ -143,6 +143,7 @@ fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: Output
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun Activity.setNavigationBarColorAndIcons() {
|
||||
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.black);
|
||||
|
||||
|
||||
@@ -5,13 +5,21 @@ import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
@@ -30,8 +38,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var _sourceHeader: SourceHeaderView;
|
||||
|
||||
|
||||
private lateinit var _sourcePermissions: LinearLayout;
|
||||
private lateinit var _sourceWarnings: LinearLayout;
|
||||
private lateinit var _sourceWarningsContainer: LinearLayout;
|
||||
|
||||
private lateinit var _container: ScrollView;
|
||||
private lateinit var _loader: ImageView;
|
||||
@@ -72,6 +82,7 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
|
||||
_sourcePermissions = findViewById(R.id.source_permissions);
|
||||
_sourceWarnings = findViewById(R.id.source_warnings);
|
||||
_sourceWarningsContainer = findViewById(R.id.container_source_warnings);
|
||||
|
||||
_container = findViewById(R.id.configContainer);
|
||||
_loader = findViewById(R.id.loader);
|
||||
@@ -194,23 +205,32 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
config.allowUrls, true)
|
||||
)
|
||||
|
||||
val pastelRed = resources.getColor(R.color.pastel_red);
|
||||
val pastelRed = ContextCompat.getColor(this, R.color.pastel_red);
|
||||
|
||||
for(warning in config.getWarnings(script))
|
||||
val warnings = config.getWarnings(script);
|
||||
for(warning in warnings)
|
||||
_sourceWarnings.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_security_pred,
|
||||
warning.first,
|
||||
warning.second)
|
||||
.withDescriptionColor(pastelRed));
|
||||
_sourceWarningsContainer.isVisible = warnings.isNotEmpty();
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fun install(config: SourcePluginConfig, script: String) {
|
||||
val isNew = !StatePlatform.instance.getAvailableClients().any { it.id == config.id };
|
||||
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
|
||||
if(it)
|
||||
if(it) {
|
||||
StatePlugins.instance.clearUpdateAvailable(config)
|
||||
if(isNew)
|
||||
lifecycleScope.launch {
|
||||
StatePlatform.instance.enableClient(listOf(config.id));
|
||||
}
|
||||
backToSources();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
lateinit var _buttonBack: ImageButton;
|
||||
|
||||
lateinit var _buttonQR: BigButton;
|
||||
lateinit var _buttonBrowse: BigButton;
|
||||
lateinit var _buttonURL: BigButton;
|
||||
lateinit var _buttonPlugins: BigButton;
|
||||
|
||||
@@ -57,6 +57,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
_buttonBrowse = findViewById(R.id.option_browse);
|
||||
_buttonURL = findViewById(R.id.option_url);
|
||||
_buttonPlugins = findViewById(R.id.option_plugins);
|
||||
|
||||
@@ -75,6 +76,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
}
|
||||
_buttonBrowse.onClick.subscribe {
|
||||
startActivity(MainActivity.getTabIntent(this, "BROWSE_PLUGINS"));
|
||||
}
|
||||
|
||||
_buttonURL.onClick.subscribe {
|
||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
||||
|
||||
@@ -26,7 +26,7 @@ class DeveloperActivity : AppCompatActivity() {
|
||||
_form = findViewById(R.id.settings_form);
|
||||
|
||||
_form.fromObject(SettingsDev.instance);
|
||||
_form.onChanged.subscribe { field, value ->
|
||||
_form.onChanged.subscribe { _, _ ->
|
||||
_form.setObjectValues();
|
||||
SettingsDev.instance.save();
|
||||
};
|
||||
|
||||
@@ -4,15 +4,19 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -26,6 +30,7 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonSubmit: LinearLayout;
|
||||
private lateinit var _buttonRestart: LinearLayout;
|
||||
private lateinit var _buttonClose: LinearLayout;
|
||||
private lateinit var _buttonCheckForUpdates: LinearLayout;
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
@@ -43,6 +48,7 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
_buttonSubmit = findViewById(R.id.button_submit);
|
||||
_buttonRestart = findViewById(R.id.button_restart);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonCheckForUpdates = findViewById(R.id.button_check_for_updates);
|
||||
|
||||
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
|
||||
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
|
||||
@@ -81,6 +87,17 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
_buttonClose.setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
_buttonCheckForUpdates.visibility = View.VISIBLE
|
||||
_buttonCheckForUpdates.setOnClickListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(this@ExceptionActivity, true, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_buttonCheckForUpdates.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitFile() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
|
||||
interface IWithResultLauncher {
|
||||
fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit);
|
||||
|
||||
@@ -3,24 +3,23 @@ package com.futo.platformplayer.activities
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -41,6 +40,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
UIDialogs.toast("Login cancelled", false);
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
|
||||
override fun finish() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
_webView?.loadUrl("about:blank");
|
||||
_webView.loadUrl("about:blank");
|
||||
}
|
||||
_callback?.let {
|
||||
_callback = null;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -16,17 +18,20 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dialogs.ConnectCastingDialog
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
@@ -36,21 +41,24 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.listeners.OrientationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
@@ -62,6 +70,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var rootView : MotionLayout;
|
||||
|
||||
private lateinit var _overlayContainer: FrameLayout;
|
||||
private lateinit var _toastView: ToastView;
|
||||
|
||||
//Segment Containers
|
||||
private lateinit var _fragContainerTopBar: FragmentContainerView;
|
||||
@@ -92,6 +101,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
|
||||
lateinit var _fragMainChannel: ChannelFragment;
|
||||
lateinit var _fragMainSources: SourcesFragment;
|
||||
lateinit var _fragMainTutorial: TutorialFragment;
|
||||
lateinit var _fragMainPlaylists: PlaylistsFragment;
|
||||
lateinit var _fragMainPlaylist: PlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
@@ -101,6 +111,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||
lateinit var _fragImportPlaylists: ImportPlaylistsFragment;
|
||||
lateinit var _fragBuy: BuyFragment;
|
||||
lateinit var _fragSubGroup: SubscriptionGroupFragment;
|
||||
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
|
||||
|
||||
lateinit var _fragBrowser: BrowserFragment;
|
||||
|
||||
@@ -132,7 +144,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
try {
|
||||
handleUrlAll(content)
|
||||
runBlocking {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to handle URL.", e)
|
||||
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
|
||||
@@ -141,6 +155,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
constructor() : super() {
|
||||
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||
val writer = StringWriter();
|
||||
|
||||
@@ -179,6 +195,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.i(TAG, "MainActivity Starting");
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
@@ -201,7 +218,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragContainerVideoDetail = findViewById(R.id.fragment_overlay);
|
||||
_fragContainerOverlay = findViewById(R.id.fragment_overlay_container);
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
//_overlayContainer.visibility = View.GONE;
|
||||
_toastView = findViewById(R.id.toast_view);
|
||||
|
||||
//Initialize fragments
|
||||
|
||||
@@ -217,6 +234,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//Main
|
||||
_fragMainHome = HomeFragment.newInstance();
|
||||
_fragMainTutorial = TutorialFragment.newInstance()
|
||||
_fragMainSuggestions = SuggestionsFragment.newInstance();
|
||||
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
|
||||
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
|
||||
@@ -236,6 +254,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||
_fragImportPlaylists = ImportPlaylistsFragment.newInstance();
|
||||
_fragBuy = BuyFragment.newInstance();
|
||||
_fragSubGroup = SubscriptionGroupFragment.newInstance();
|
||||
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
|
||||
|
||||
_fragBrowser = BrowserFragment.newInstance();
|
||||
|
||||
@@ -306,6 +326,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
|
||||
_fragMainPlaylistSearchResults.topBar = _fragTopBarSearch;
|
||||
_fragMainChannel.topBar = _fragTopBarNavigation;
|
||||
_fragMainTutorial.topBar = _fragTopBarNavigation;
|
||||
_fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral;
|
||||
_fragMainSources.topBar = _fragTopBarAdd;
|
||||
_fragMainPlaylists.topBar = _fragTopBarGeneral;
|
||||
@@ -317,9 +338,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragDownloads.topBar = _fragTopBarGeneral;
|
||||
_fragImportSubscriptions.topBar = _fragTopBarImport;
|
||||
_fragImportPlaylists.topBar = _fragTopBarImport;
|
||||
_fragSubGroupList.topBar = _fragTopBarAdd;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
|
||||
fragCurrent = _fragMainHome;
|
||||
|
||||
val defaultTab = Settings.instance.tabs.mapNotNull {
|
||||
@@ -401,6 +423,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateApp.instance.mainAppStartedWithExternalFiles(this);
|
||||
|
||||
//startActivity(Intent(this, TestActivity::class.java));
|
||||
|
||||
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
|
||||
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
|
||||
if (isFirstBoot) {
|
||||
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
|
||||
navigate(_fragMainTutorial)
|
||||
})
|
||||
|
||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,21 +489,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
_isVisible = true;
|
||||
val videoToOpen = StateSaved.instance.videoToOpen;
|
||||
|
||||
if (_wasStopped) {
|
||||
_wasStopped = false;
|
||||
|
||||
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
|
||||
StateSaved.instance.setVideoToOpenNonBlocking(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -526,13 +543,28 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if(it is MainActivity) {
|
||||
runBlocking {
|
||||
it.handleUrlAll(req.url.toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
handleUrlAll(targetData)
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -540,7 +572,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrlAll(url: String) {
|
||||
suspend fun handleUrlAll(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
@@ -576,7 +608,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]",
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -624,23 +656,38 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(url: String): Boolean {
|
||||
suspend fun handleUrl(url: String): Boolean {
|
||||
Logger.i(TAG, "handleUrl(url=$url)")
|
||||
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
navigate(_fragVideoDetail, url);
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
return true;
|
||||
} else if(StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
navigate(_fragMainChannel, url);
|
||||
return withContext(Dispatchers.IO) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragVideoDetail, url);
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return true;
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainPlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
}
|
||||
return@withContext false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleContent(file: String, mime: String? = null): Boolean {
|
||||
Logger.i(TAG, "handleContent(url=$file)");
|
||||
@@ -649,12 +696,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if(file.lowercase().endsWith(".json") || mime == "application/json") {
|
||||
var recon = String(data);
|
||||
if(!recon.trim().startsWith("["))
|
||||
return handleUnknownJson(file, recon);
|
||||
return handleUnknownJson(recon);
|
||||
|
||||
var reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
|
||||
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
|
||||
var cache: ImportCache? = null;
|
||||
try {
|
||||
if(cacheStr != null)
|
||||
cache = Json.decodeFromString(cacheStr);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize cache");
|
||||
}
|
||||
|
||||
|
||||
val reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
recon = reconLines.joinToString("\n");
|
||||
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
|
||||
handleReconstruction(recon);
|
||||
handleReconstruction(recon, cache);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
|
||||
@@ -669,12 +728,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fun handleFile(file: String): Boolean {
|
||||
Logger.i(TAG, "handleFile(url=$file)");
|
||||
if(file.lowercase().endsWith(".json")) {
|
||||
val recon = String(readSharedFile(file));
|
||||
var recon = String(readSharedFile(file));
|
||||
if(!recon.startsWith("["))
|
||||
return handleUnknownJson(file, recon);
|
||||
return handleUnknownJson(recon);
|
||||
|
||||
var reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
|
||||
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
|
||||
var cache: ImportCache? = null;
|
||||
try {
|
||||
if(cacheStr != null)
|
||||
cache = Json.decodeFromString(cacheStr);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize cache");
|
||||
}
|
||||
recon = reconLines.joinToString("\n");
|
||||
|
||||
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
|
||||
handleReconstruction(recon);
|
||||
handleReconstruction(recon, cache);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".zip")) {
|
||||
@@ -686,7 +758,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleReconstruction(recon: String) {
|
||||
fun handleReconstruction(recon: String, cache: ImportCache? = null) {
|
||||
val type = ManagedStore.getReconstructionIdentifier(recon);
|
||||
val store: ManagedStore<*> = when(type) {
|
||||
"Playlist" -> StatePlaylists.instance.playlistStore
|
||||
@@ -703,7 +775,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
|
||||
if(!type.isNullOrEmpty()) {
|
||||
UIDialogs.showImportDialog(this, store, name, listOf(recon)) {
|
||||
UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -723,7 +795,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleUnknownJson(name: String?, json: String): Boolean {
|
||||
fun handleUnknownJson(json: String): Boolean {
|
||||
|
||||
val context = this;
|
||||
|
||||
@@ -793,11 +865,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if(_fragBotBarMenu.onBackPressed())
|
||||
return;
|
||||
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED &&
|
||||
_fragVideoDetail.onBackPressed())
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
|
||||
if(!fragCurrent.onBackPressed())
|
||||
closeSegment();
|
||||
}
|
||||
@@ -832,7 +902,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED;
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
}
|
||||
|
||||
@@ -843,7 +913,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_orientationManager.disable();
|
||||
|
||||
StateApp.instance.mainAppDestroyed(this);
|
||||
StateSaved.instance.setVideoToOpenBlocking(null);
|
||||
}
|
||||
|
||||
inline fun <reified T> isFragmentActive(): Boolean {
|
||||
@@ -854,6 +923,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
*/
|
||||
@SuppressLint("CommitTransaction")
|
||||
fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)")
|
||||
|
||||
@@ -889,7 +959,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||
|
||||
val extraBottomDP = if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) HEIGHT_VIDEO_MINIMIZED_DP else 0f
|
||||
if (segment.hasBottomBar) {
|
||||
if (!fragCurrent.hasBottomBar)
|
||||
transaction = transaction.show(_fragBotBarMenu);
|
||||
@@ -899,13 +968,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
transaction = transaction.hide(_fragBotBarMenu);
|
||||
}
|
||||
transaction.commitNow();
|
||||
}
|
||||
else {
|
||||
//Special cases
|
||||
if(segment is VideoDetailFragment) {
|
||||
_fragContainerVideoDetail.visibility = View.VISIBLE;
|
||||
_fragVideoDetail.maximizeVideoDetail();
|
||||
}
|
||||
} else {
|
||||
|
||||
if(!segment.hasBottomBar) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
@@ -965,6 +1028,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
inline fun <reified T : Fragment> getFragment() : T {
|
||||
return when(T::class) {
|
||||
HomeFragment::class -> _fragMainHome as T;
|
||||
TutorialFragment::class -> _fragMainTutorial as T;
|
||||
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
|
||||
CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T;
|
||||
SuggestionsFragment::class -> _fragMainSuggestions as T;
|
||||
@@ -989,6 +1053,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
ImportPlaylistsFragment::class -> _fragImportPlaylists as T;
|
||||
BrowserFragment::class -> _fragBrowser as T;
|
||||
BuyFragment::class -> _fragBuy as T;
|
||||
SubscriptionGroupFragment::class -> _fragSubGroup as T;
|
||||
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
|
||||
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||
}
|
||||
}
|
||||
@@ -1008,6 +1074,70 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
}
|
||||
fun requestNotificationPermissions(reason: String) {
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
|
||||
|
||||
}
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
|
||||
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
|
||||
reason, null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Enable", {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
else -> {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _toastQueue = ConcurrentLinkedQueue<ToastView.Toast>();
|
||||
private var _toastJob: Job? = null;
|
||||
fun showAppToast(toast: ToastView.Toast) {
|
||||
synchronized(_toastQueue) {
|
||||
_toastQueue.add(toast);
|
||||
if(_toastJob?.isActive != true)
|
||||
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
|
||||
launchAppToastJob();
|
||||
};
|
||||
}
|
||||
}
|
||||
private suspend fun launchAppToastJob() {
|
||||
Logger.i(TAG, "Starting appToast loop");
|
||||
while(!_toastQueue.isEmpty()) {
|
||||
val toast = _toastQueue.poll() ?: continue;
|
||||
Logger.i(TAG, "Showing next toast (${toast.msg})");
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (!_toastView.isVisible) {
|
||||
Logger.i(TAG, "First showing toast");
|
||||
_toastView.setToast(toast);
|
||||
_toastView.show(true);
|
||||
} else {
|
||||
_toastView.setToastAnimated(toast);
|
||||
}
|
||||
}
|
||||
if(toast.long)
|
||||
delay(5000);
|
||||
else
|
||||
delay(3000);
|
||||
}
|
||||
Logger.i(TAG, "Ending appToast loop");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
_toastView.hide(true) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
|
||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
||||
|
||||
@@ -55,10 +55,10 @@ class ManageTabsActivity : AppCompatActivity() {
|
||||
Settings.instance.save()
|
||||
}
|
||||
|
||||
val items = Settings.instance.tabs.mapNotNull {
|
||||
val items = ArrayList(Settings.instance.tabs.mapNotNull {
|
||||
val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.find { d -> it.id == d.id } ?: return@mapNotNull null
|
||||
TabViewHolderData(buttonDefinition, it.enabled)
|
||||
};
|
||||
});
|
||||
|
||||
_listTabs = _recyclerTabs.asAny(items) {
|
||||
it.onDragDrop.subscribe { vh ->
|
||||
|
||||
+10
-3
@@ -12,14 +12,14 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -71,7 +71,14 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
try {
|
||||
processHandle = ProcessHandle.create();
|
||||
Store.instance.addProcessSecret(processHandle.processSecret);
|
||||
processHandle.addServer("https://srv1-stg.polycentric.io");
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
processHandle.addServer(PolycentricCache.SERVER);
|
||||
processHandle.setUsername(username);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+59
-30
@@ -12,14 +12,20 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.*
|
||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||
import com.futo.polycentric.core.KeyPair
|
||||
import com.futo.polycentric.core.Process
|
||||
import com.futo.polycentric.core.ProcessSecret
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.base64UrlToByteArray
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
@@ -30,6 +36,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonScanProfile: LinearLayout;
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
@@ -53,6 +60,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_editProfile = findViewById(R.id.edit_profile);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
@@ -95,42 +103,63 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
_loaderOverlay.show()
|
||||
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
if (existingProcessSecret != null) {
|
||||
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
|
||||
return;
|
||||
}
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
if (existingProcessSecret != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
||||
}
|
||||
return@launch;
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide();
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-23
@@ -1,6 +1,8 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -12,25 +14,26 @@ import android.webkit.MimeTypeMap
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dialogs.CommentDialog
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.toURLInfoDataLink
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -48,6 +51,8 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonDelete: BigButton;
|
||||
private lateinit var _username: String;
|
||||
private lateinit var _imagePolycentric: ImageView;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
private lateinit var _textSystem: TextView;
|
||||
private var _avatarUri: Uri? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
@@ -65,28 +70,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
_buttonExport = findViewById(R.id.button_export);
|
||||
_buttonLogout = findViewById(R.id.button_logout);
|
||||
_buttonDelete = findViewById(R.id.button_delete);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_textSystem = findViewById(R.id.text_system)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
saveIfRequired();
|
||||
finish();
|
||||
};
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io");
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateUI();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
_imagePolycentric.setOnClickListener {
|
||||
ImagePicker.with(this)
|
||||
.cropSquare()
|
||||
@@ -122,6 +112,37 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
_textSystem.setOnLongClickListener {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip: ClipData = ClipData.newPlainText("system", _textSystem.text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
return@setOnLongClickListener true
|
||||
}
|
||||
|
||||
updateUI()
|
||||
|
||||
StatePolycentric.instance.processHandle?.let { processHandle ->
|
||||
_loaderOverlay.show()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateUI();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveIfRequired() {
|
||||
@@ -130,13 +151,17 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
var hasChanges = false;
|
||||
val username = _editName.text.toString();
|
||||
if (username.length < 3) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
|
||||
}
|
||||
return@launch;
|
||||
}
|
||||
|
||||
val processHandle = StatePolycentric.instance.processHandle;
|
||||
if (processHandle == null) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
|
||||
}
|
||||
return@launch;
|
||||
}
|
||||
|
||||
@@ -221,6 +246,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private fun updateUI() {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system))
|
||||
_textSystem.text = processHandle.system.key.toBase64Url()
|
||||
_username = systemState.username;
|
||||
_editName.text.clear();
|
||||
_editName.text.append(_username);
|
||||
@@ -250,7 +276,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? {
|
||||
var mimeType: String? = null;
|
||||
var mimeType: String?;
|
||||
|
||||
// Try to get MIME type from the content URI
|
||||
mimeType = contentResolver.getType(uri);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
@@ -12,6 +14,8 @@ import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -33,6 +37,14 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
|
||||
lateinit var overlay: FrameLayout;
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
@@ -49,7 +61,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
_loaderView = findViewById(R.id.loader);
|
||||
overlay = findViewById(R.id.overlay_container);
|
||||
|
||||
_form.onChanged.subscribe { field, value ->
|
||||
_form.onChanged.subscribe { field, _ ->
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
@@ -58,6 +70,33 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
||||
}
|
||||
|
||||
if(field.descriptor?.id == "background_update") {
|
||||
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
|
||||
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
if(!notifManager.areNotificationsEnabled()) {
|
||||
UIDialogs.toast(this, "Notifications aren't enabled");
|
||||
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
|
||||
|
||||
}
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
|
||||
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
|
||||
"Notifications need to be enabled for background updating to function", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Enable", {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
else -> {
|
||||
requestPermissionLauncher.launch(notifPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
@@ -72,7 +111,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
reloadSettings();
|
||||
}
|
||||
|
||||
var isFirstLoad = true;
|
||||
fun reloadSettings() {
|
||||
val firstLoad = isFirstLoad;
|
||||
isFirstLoad = false;
|
||||
_form.setSearchVisible(false);
|
||||
_loaderView.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
@@ -90,6 +132,13 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
|
||||
}
|
||||
};
|
||||
|
||||
if(firstLoad) {
|
||||
val query = intent.getStringExtra("query");
|
||||
if(!query.isNullOrEmpty()) {
|
||||
_form.setSearchQuery(query);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +184,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
|
||||
@@ -13,8 +13,6 @@ import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.Dictionary
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
open class ManagedHttpClient {
|
||||
@@ -60,7 +58,7 @@ open class ManagedHttpClient {
|
||||
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.url(url);
|
||||
if(user_agent != null && !user_agent.isEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
requestBuilder.addHeader("User-Agent", user_agent)
|
||||
|
||||
for (pair in headers.entries)
|
||||
@@ -137,7 +135,7 @@ open class ManagedHttpClient {
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.method(request.method, requestBody)
|
||||
.url(request.url);
|
||||
if(user_agent != null && !user_agent.isEmpty() && !request.headers.any { it.key.lowercase() == "user-agent" })
|
||||
if(user_agent.isNotEmpty() && !request.headers.any { it.key.lowercase() == "user-agent" })
|
||||
requestBuilder.addHeader("User-Agent", user_agent)
|
||||
|
||||
for (pair in request.headers.entries)
|
||||
@@ -148,7 +146,7 @@ open class ManagedHttpClient {
|
||||
|
||||
val time = measureTimeMillis {
|
||||
val call = client.newCall(requestBuilder.build());
|
||||
request.onCallCreated?.emit(call);
|
||||
request.onCallCreated.emit(call);
|
||||
response = call.execute()
|
||||
resp = Response(
|
||||
response.code,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.futo.platformplayer.api.http.server
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.reflect.Field
|
||||
@@ -14,11 +14,10 @@ import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
private val _client : ManagedHttpClient = ManagedHttpClient();
|
||||
@@ -192,7 +191,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
}
|
||||
fun addBridgeHandlers(obj: Any, tag: String? = null) {
|
||||
val tagToUse = tag ?: obj.javaClass.name;
|
||||
//val tagToUse = tag ?: obj.javaClass.name;
|
||||
val getMethods = obj::class.java.declaredMethods
|
||||
.filter { it.getAnnotation(HttpGET::class.java) != null }
|
||||
.map { Pair<Method, HttpGET>(it, it.getAnnotation(HttpGET::class.java)!!) }
|
||||
@@ -212,13 +211,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||
if(!getMethod.second.contentType.isEmpty())
|
||||
this.withContentType(getMethod.second.contentType);
|
||||
}.withContentType(getMethod.second.contentType ?: "");
|
||||
}.withContentType(getMethod.second.contentType);
|
||||
for(postMethod in postMethods)
|
||||
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
||||
addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||
if(!postMethod.second.contentType.isEmpty())
|
||||
this.withContentType(postMethod.second.contentType);
|
||||
}.withContentType(postMethod.second.contentType ?: "");
|
||||
}.withContentType(postMethod.second.contentType);
|
||||
|
||||
for(getField in getFields) {
|
||||
getField.first.isAccessible = true;
|
||||
@@ -232,13 +231,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
else
|
||||
it.respondCode(204);
|
||||
}).withContentType(getField.second.contentType ?: "");
|
||||
}).withContentType(getField.second.contentType);
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
|
||||
val stopCount = _stopCount;
|
||||
var keepAlive = false;
|
||||
var keepAlive: Boolean;
|
||||
var requestsMax = 0;
|
||||
var requestsTotal = 0;
|
||||
do {
|
||||
@@ -288,11 +287,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
for (intf in NetworkInterface.getNetworkInterfaces()) {
|
||||
for (addr in intf.inetAddresses) {
|
||||
if (!addr.isLoopbackAddress) {
|
||||
val ipString: String = addr.hostAddress;
|
||||
val isIPv4 = ipString.indexOf(':') < 0;
|
||||
if (!isIPv4)
|
||||
continue;
|
||||
addresses.add(addr);
|
||||
val ipString: String = addr.hostAddress ?: continue
|
||||
val isIPv4 = ipString.indexOf(':') < 0
|
||||
if (!isIPv4) {
|
||||
continue
|
||||
}
|
||||
|
||||
addresses.add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
package com.futo.platformplayer.api.http.server.exceptions
|
||||
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class EmptyRequestException(msg: String) : Exception(msg) {}
|
||||
@@ -10,12 +10,9 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
|
||||
/**
|
||||
* A temporary class that caches video results
|
||||
@@ -44,8 +41,7 @@ class CachedPlatformClient : IPlatformClient {
|
||||
var result = _cache.get(url);
|
||||
if(result == null) {
|
||||
result = _client.getContentDetails(url);
|
||||
if (result != null)
|
||||
_cache.put(url, result);
|
||||
_cache.put(url, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -64,6 +60,9 @@ class CachedPlatformClient : IPlatformClient {
|
||||
filters: Map<String, List<String>>?
|
||||
): IPager<IPlatformContent> = _client.getChannelContents(channelUrl);
|
||||
|
||||
override fun getPeekChannelTypes(): List<String> = _client.getPeekChannelTypes();
|
||||
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = _client.peekChannelContents(channelUrl, type);
|
||||
|
||||
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String? = _client.getChannelUrlByClaim(claimType, claimValues)
|
||||
|
||||
override fun searchSuggestions(query: String): Array<String> = _client.searchSuggestions(query);
|
||||
|
||||
@@ -10,11 +10,9 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
|
||||
/**
|
||||
* A client for a specific platform
|
||||
@@ -86,6 +84,15 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map<String, List<String>>? = null): IPager<IPlatformContent>;
|
||||
|
||||
/**
|
||||
* Describes what the plugin is capable on peek channel results
|
||||
*/
|
||||
fun getPeekChannelTypes(): List<String>;
|
||||
/**
|
||||
* Peeks contents of a channel, upload time descending
|
||||
*/
|
||||
fun peekChannelContents(channelUrl: String, type: String? = null): List<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets the channel url associated with a claimType
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.caverock.androidsvg.SVG
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventDonation
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
@@ -195,7 +194,7 @@ class LiveChatManager {
|
||||
|
||||
fun getEmojiDrawable(emoji: String, cb: (drawable: Drawable?)->Unit) {
|
||||
var drawable: Drawable? = null;
|
||||
var url: String? = null;
|
||||
var url: String?;
|
||||
synchronized(_cache_lock) {
|
||||
url = _cache_urls[emoji];
|
||||
if(url != null)
|
||||
|
||||
@@ -13,10 +13,12 @@ data class PlatformClientCapabilities(
|
||||
val hasGetChannelUrlByClaim: Boolean = false,
|
||||
val hasGetChannelTemplateByClaimMap: Boolean = false,
|
||||
val hasGetSearchCapabilities: Boolean = false,
|
||||
val hasGetSearchChannelContentsCapabilities: Boolean = false,
|
||||
val hasGetChannelCapabilities: Boolean = false,
|
||||
val hasGetLiveEvents: Boolean = false,
|
||||
val hasGetLiveChatWindow: Boolean = false,
|
||||
val hasGetContentChapters: Boolean = false
|
||||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class PlatformMultiClientPool {
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
||||
this.onDead.subscribe { client, pool ->
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
_clientPools.remove(parentClient);
|
||||
|
||||
@@ -14,7 +14,7 @@ open class PlatformAuthorLink {
|
||||
val id: PlatformID;
|
||||
val name: String;
|
||||
val url: String;
|
||||
val thumbnail: String?;
|
||||
var thumbnail: String?;
|
||||
var subscribers: Long? = null; //Optional
|
||||
|
||||
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null)
|
||||
|
||||
@@ -64,7 +64,6 @@ class FilterGroup(
|
||||
val isMultiSelect: Boolean,
|
||||
val id: String? = null
|
||||
) {
|
||||
@kotlinx.serialization.Transient
|
||||
val idOrName: String get() = id ?: name;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.futo.platformplayer.api.media.models
|
||||
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.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -31,7 +33,7 @@ class Thumbnails {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
||||
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
||||
.toArray()
|
||||
.map { Thumbnail.fromV8(it as V8ValueObject) }
|
||||
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
||||
.toTypedArray());
|
||||
}
|
||||
}
|
||||
@@ -40,10 +42,10 @@ class Thumbnails {
|
||||
data class Thumbnail(val url : String?, val quality : Int = 0) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(value: V8ValueObject): Thumbnail {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnail {
|
||||
return Thumbnail(
|
||||
value.getString("url"),
|
||||
value.getInteger("quality"));
|
||||
value.getOrDefault<String>(config,"url", "Thumbnail", null),
|
||||
value.getOrDefault(config, "quality", "Thumbnail", 0) ?: 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
+4
@@ -37,6 +37,10 @@ class SerializedChannel(
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
fun isSameUrl(url: String): Boolean {
|
||||
return this.url == url || urlAlternatives.contains(url);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromChannel(channel: IPlatformChannel): SerializedChannel {
|
||||
return SerializedChannel(
|
||||
|
||||
@@ -14,7 +14,8 @@ enum class ChapterType(val value: Int) {
|
||||
NORMAL(0),
|
||||
|
||||
SKIPPABLE(5),
|
||||
SKIP(6);
|
||||
SKIP(6),
|
||||
SKIPONCE(7);
|
||||
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -19,8 +19,9 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
|
||||
val eventPointer: Pointer;
|
||||
val reference: Reference;
|
||||
val parentReference: Reference?;
|
||||
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, parentReference: Reference?, replyCount: Int? = null) {
|
||||
this.contextUrl = contextUrl;
|
||||
this.author = author;
|
||||
this.message = msg;
|
||||
@@ -29,6 +30,7 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
this.replyCount = replyCount;
|
||||
this.eventPointer = eventPointer;
|
||||
this.reference = eventPointer.toReference();
|
||||
this.parentReference = parentReference;
|
||||
}
|
||||
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
||||
@@ -36,10 +38,11 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
}
|
||||
|
||||
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, parentReference, replyCount);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricPlatformComment"
|
||||
val MAX_COMMENT_SIZE = 2000
|
||||
}
|
||||
}
|
||||
+4
-12
@@ -1,31 +1,23 @@
|
||||
package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingScaler
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingType
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orDefault
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
val type : LiveEventType;
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IPlatformLiveEvent {
|
||||
val contextName = "LiveEvent";
|
||||
val type = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(type) {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
|
||||
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
|
||||
LiveEventType.EMOJIS -> LiveEventEmojis.fromV8(config, obj);
|
||||
LiveEventType.DONATION -> LiveEventDonation.fromV8(config, obj);
|
||||
LiveEventType.VIEWCOUNT -> LiveEventViewCount.fromV8(config, obj);
|
||||
LiveEventType.RAID -> LiveEventRaid.fromV8(config, obj);
|
||||
else -> throw NotImplementedError("Unknown type ${type}");
|
||||
else -> throw NotImplementedError("Unknown type $t");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.futo.platformplayer.api.media.models.modifier
|
||||
|
||||
class AdhocRequestModifier: IRequestModifier {
|
||||
val _handler: (String, Map<String,String>)->IRequest;
|
||||
override var allowByteSkip: Boolean = false;
|
||||
|
||||
constructor(modifyReq: (String, Map<String,String>)->IRequest) {
|
||||
_handler = modifyReq;
|
||||
}
|
||||
|
||||
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||
return _handler(url, headers);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.api.media.models.modifier
|
||||
|
||||
interface IModifierOptions {
|
||||
val applyAuthClient: String?;
|
||||
val applyCookieClient: String?;
|
||||
val applyOtherHeaders: Boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.models.modifier
|
||||
|
||||
interface IRequest {
|
||||
val url: String?;
|
||||
val headers: Map<String, String>;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.api.media.models.modifier
|
||||
|
||||
|
||||
interface IRequestModifier {
|
||||
var allowByteSkip: Boolean;
|
||||
fun modifyRequest(url: String, headers: Map<String, String>): IRequest
|
||||
}
|
||||
-3
@@ -1,9 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.models.playlists
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
|
||||
interface IPlatformPlaylist : IPlatformContent {
|
||||
val thumbnail: String?;
|
||||
|
||||
-4
@@ -2,10 +2,6 @@ package com.futo.platformplayer.api.media.models.post
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
|
||||
/**
|
||||
* A detailed video model with data including video/audio sources
|
||||
|
||||
@@ -14,14 +14,13 @@ interface IRating {
|
||||
|
||||
companion object {
|
||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) };
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IRating {
|
||||
val contextName = "Rating";
|
||||
val type = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(type) {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
|
||||
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
|
||||
RatingType.LIKEDISLIKES -> RatingLikeDislikes.fromV8(config, obj);
|
||||
RatingType.SCALE -> RatingScaler.fromV8(config, obj);
|
||||
else -> throw NotImplementedError("Unknown type ${type}");
|
||||
else -> throw NotImplementedError("Unknown type $t");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IAudioUrlWidevineSource : IAudioUrlSource {
|
||||
val bearerToken: String
|
||||
val licenseUri: String
|
||||
}
|
||||
-2
@@ -1,8 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.models.subtitles
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
interface ISubtitleSource {
|
||||
val name: String;
|
||||
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
|
||||
/**
|
||||
* A detailed video model with data including video/audio sources
|
||||
|
||||
-1
@@ -8,6 +8,5 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
class SerializedVideoMuxedSourceDescriptor(
|
||||
val _videoSources: Array<VideoUrlSource>
|
||||
): VideoMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor {
|
||||
@kotlinx.serialization.Transient
|
||||
override val videoSources: Array<IVideoSource> get() = _videoSources.map { it }.toTypedArray();
|
||||
};
|
||||
+4
-3
@@ -1,15 +1,16 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class SerializedVideoNonMuxedSourceDescriptor(
|
||||
val _videoSources: Array<VideoUrlSource>,
|
||||
val _audioSources: Array<AudioUrlSource>
|
||||
): VideoUnMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor {
|
||||
@kotlinx.serialization.Transient
|
||||
override val videoSources: Array<IVideoSource> get() = _videoSources.map { it }.toTypedArray();
|
||||
@kotlinx.serialization.Transient
|
||||
override val audioSources: Array<IAudioSource> get() = _audioSources.map { it }.toTypedArray();
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.util.*
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import java.util.UUID
|
||||
|
||||
class DevJSClient : JSClient {
|
||||
override val id: String
|
||||
@@ -20,14 +20,14 @@ class DevJSClient : JSClient {
|
||||
|
||||
val devID: String;
|
||||
|
||||
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV")), null, script) {
|
||||
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null, settings: HashMap<String, String?>? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV"), settings), null, script) {
|
||||
_devScript = script;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
|
||||
onCaptchaException.subscribe { client, captcha ->
|
||||
StateApp.instance.handleCaptchaException(client, captcha);
|
||||
onCaptchaException.subscribe { client, c ->
|
||||
StateApp.instance.handleCaptchaException(client, c);
|
||||
}
|
||||
}
|
||||
//TODO: Misisng auth/captcha pass on purpose?
|
||||
@@ -37,8 +37,8 @@ class DevJSClient : JSClient {
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
|
||||
onCaptchaException.subscribe { client, captcha ->
|
||||
StateApp.instance.handleCaptchaException(client, captcha);
|
||||
onCaptchaException.subscribe { client, c ->
|
||||
StateApp.instance.handleCaptchaException(client, c);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class DevJSClient : JSClient {
|
||||
_auth = auth;
|
||||
}
|
||||
fun recreate(context: Context): DevJSClient {
|
||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
|
||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||
}
|
||||
|
||||
override fun getCopy(): JSClient {
|
||||
|
||||
@@ -4,12 +4,10 @@ import android.content.Context
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueNull
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
@@ -23,43 +21,63 @@ import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.*
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.*
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSCommentPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSContentPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.PluginEngineException
|
||||
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
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.ScriptValidationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
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.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.Exception
|
||||
import kotlin.reflect.full.findAnnotations
|
||||
import kotlin.reflect.jvm.kotlinFunction
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
open class JSClient : IPlatformClient {
|
||||
val config: SourcePluginConfig;
|
||||
protected val _context: Context;
|
||||
private val _plugin: V8Plugin;
|
||||
private val plugin: V8Plugin get() = _plugin ?: throw IllegalStateException("Client not enabled");
|
||||
private val plugin: V8Plugin get() = _plugin
|
||||
|
||||
var descriptor: SourcePluginDescriptor
|
||||
private set;
|
||||
|
||||
private val _client: JSHttpClient;
|
||||
private val _clientAuth: JSHttpClient?;
|
||||
private val _httpClient: JSHttpClient;
|
||||
private val _httpClientAuth: JSHttpClient?;
|
||||
private var _searchCapabilities: ResultCapabilities? = null;
|
||||
private var _searchChannelContentsCapabilities: ResultCapabilities? = null;
|
||||
private var _channelCapabilities: ResultCapabilities? = null;
|
||||
private var _peekChannelTypes: List<String>? = null;
|
||||
|
||||
protected val _script: String;
|
||||
|
||||
@@ -78,7 +96,11 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
private val _busyLock = Object();
|
||||
private var _busyCounter = 0;
|
||||
private var _busyAction = "";
|
||||
val isBusy: Boolean get() = _busyCounter > 0;
|
||||
val isBusyAction: String get() {
|
||||
return _busyAction;
|
||||
}
|
||||
|
||||
val settings: HashMap<String, String?> get() = descriptor.settings;
|
||||
|
||||
@@ -119,9 +141,9 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_client = JSHttpClient(this, null, _captcha);
|
||||
_clientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
@@ -137,6 +159,8 @@ open class JSClient : IPlatformClient {
|
||||
if(it is ScriptCaptchaRequiredException)
|
||||
onCaptchaException.emit(this, it);
|
||||
};
|
||||
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
|
||||
this._context = context;
|
||||
@@ -148,9 +172,9 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_client = JSHttpClient(this, null, _captcha);
|
||||
_clientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
_plugin.withScript(script);
|
||||
@@ -160,6 +184,8 @@ open class JSClient : IPlatformClient {
|
||||
if(it is ScriptCaptchaRequiredException)
|
||||
onCaptchaException.emit(this, it);
|
||||
};
|
||||
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(): JSClient {
|
||||
@@ -169,6 +195,13 @@ open class JSClient : IPlatformClient {
|
||||
fun getUnderlyingPlugin(): V8Plugin {
|
||||
return _plugin;
|
||||
}
|
||||
fun getHttpClientById(id: String): JSHttpClient? {
|
||||
if(_httpClient.clientId == id)
|
||||
return _httpClient;
|
||||
if(_httpClientAuth?.clientId == id)
|
||||
return _httpClientAuth;
|
||||
return plugin.httpClientOthers[id];
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
@@ -194,9 +227,11 @@ open class JSClient : IPlatformClient {
|
||||
hasGetChannelTemplateByClaimMap = plugin.executeBoolean("!!source.getChannelTemplateByClaimMap") ?: false,
|
||||
hasGetSearchCapabilities = plugin.executeBoolean("!!source.getSearchCapabilities") ?: false,
|
||||
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
|
||||
hasGetSearchChannelContentsCapabilities = plugin.executeBoolean("!!source.getSearchChannelContentsCapabilities") ?: false,
|
||||
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
|
||||
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -240,15 +275,15 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform")
|
||||
override fun getHome(): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun getHome(): IPager<IPlatformContent> = isBusyWith("getHome") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, plugin,
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.getHome()"));
|
||||
}
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith {
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith plugin.executeTyped<V8ValueArray>("source.searchSuggestions(${Json.encodeToString(query)})")
|
||||
.toArray()
|
||||
@@ -278,14 +313,17 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
@JSDocsParameter("channelId", "(optional) Channel id to search in")
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("search") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, plugin,
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
}
|
||||
|
||||
@JSDocs(4, "source.getSearchChannelContentsCapabilities()", "Gets capabilities this plugin has for search videos")
|
||||
override fun getSearchChannelContentsCapabilities(): ResultCapabilities {
|
||||
if(!capabilities.hasGetSearchChannelContentsCapabilities)
|
||||
return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED));
|
||||
|
||||
ensureEnabled();
|
||||
if (_searchChannelContentsCapabilities != null)
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
@@ -299,21 +337,21 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from search ")
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchChannelContents") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSearchChannelContents)
|
||||
throw IllegalStateException("This plugin does not support channel search");
|
||||
|
||||
return@isBusyWith JSContentPager(config, plugin,
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.searchChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform")
|
||||
@JSDocsParameter("query", "Query that channels should match")
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith {
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith("searchChannels") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannelPager(config, plugin,
|
||||
return@isBusyWith JSChannelPager(config, this,
|
||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||
}
|
||||
|
||||
@@ -331,7 +369,7 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
|
||||
@JSDocsParameter("channelUrl", "A channel url (this platform)")
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith {
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith("getChannel") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannel(config,
|
||||
plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})"));
|
||||
@@ -358,12 +396,46 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("getChannelContents") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, plugin,
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
}
|
||||
|
||||
@JSDocs(10, "source.getPeekChannelTypes()", "Gets types this plugin has for peek channel contents")
|
||||
override fun getPeekChannelTypes(): List<String> {
|
||||
if(!capabilities.hasPeekChannelContents)
|
||||
return listOf();
|
||||
try {
|
||||
if (_peekChannelTypes != null) {
|
||||
return _peekChannelTypes!!;
|
||||
}
|
||||
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
|
||||
|
||||
_peekChannelTypes = arr.keys.mapNotNull {
|
||||
val str = arr.get<V8ValueString>(it);
|
||||
return@mapNotNull str.value;
|
||||
};
|
||||
return _peekChannelTypes ?: listOf();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getPeekChannelTypes", ex);
|
||||
return listOf();
|
||||
}
|
||||
}
|
||||
@JSDocs(10, "source.peekChannelContents(url, type)", "Peek contents of a channel (reverse chronological order)")
|
||||
@JSDocsParameter("channelUrl", "A channel url (this platform)")
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
|
||||
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = isBusyWith("peekChannelContents") {
|
||||
ensureEnabled();
|
||||
|
||||
val items: V8ValueArray = plugin.executeTyped("source.peekChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)})");
|
||||
return@isBusyWith items.keys.mapNotNull {
|
||||
val obj = items.get<V8ValueObject>(it);
|
||||
return@mapNotNull IJSContent.fromV8(this, obj);
|
||||
};
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim")
|
||||
@JSDocsParameter("claimType", "Polycentric claimtype id")
|
||||
@@ -424,16 +496,16 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith {
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith("getContentDetails") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith IJSContentDetails.fromV8(config,
|
||||
return@isBusyWith IJSContentDetails.fromV8(this,
|
||||
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
@JSOptional //getContentChapters = function(url, initialData)
|
||||
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getContentChapters(url: String): List<IChapter> = isBusyWith {
|
||||
override fun getContentChapters(url: String): List<IChapter> = isBusyWith("getContentChapters") {
|
||||
if(!capabilities.hasGetContentChapters)
|
||||
return@isBusyWith listOf();
|
||||
ensureEnabled();
|
||||
@@ -444,7 +516,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSOptional
|
||||
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith {
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith("getPlaybackTracker") {
|
||||
if(!capabilities.hasGetPlaybackTracker)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
@@ -458,25 +530,25 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith("getComments") {
|
||||
ensureEnabled();
|
||||
val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
|
||||
if (pager !is V8ValueObject) { //TODO: Maybe solve this better
|
||||
return@isBusyWith EmptyPager<IPlatformComment>();
|
||||
}
|
||||
return@isBusyWith JSCommentPager(config, plugin, pager);
|
||||
return@isBusyWith JSCommentPager(config, this, pager);
|
||||
}
|
||||
@JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment")
|
||||
@JSDocsParameter("comment", "Comment object that was returned by getComments")
|
||||
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
|
||||
ensureEnabled();
|
||||
return comment.getReplies(this) ?: JSCommentPager(config, plugin,
|
||||
return comment.getReplies(this) ?: JSCommentPager(config, this,
|
||||
plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})"));
|
||||
}
|
||||
|
||||
@JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream")
|
||||
@JSDocsParameter("url", "Url of live stream")
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith {
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") {
|
||||
if(!capabilities.hasGetLiveChatWindow)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
@@ -485,11 +557,11 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
@JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream")
|
||||
@JSDocsParameter("url", "Url of live stream")
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith {
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith("getLiveEvents") {
|
||||
if(!capabilities.hasGetLiveEvents)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSLiveEventPager(config, plugin,
|
||||
return@isBusyWith JSLiveEventPager(config, this,
|
||||
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
|
||||
}
|
||||
@JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform")
|
||||
@@ -498,27 +570,34 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
@JSDocsParameter("channelId", "(optional) Channel id to search in")
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchPlaylists") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSearchPlaylists)
|
||||
throw IllegalStateException("This plugin does not support playlist search");
|
||||
return@isBusyWith JSContentPager(config, plugin, plugin.executeTyped("source.searchPlaylists(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.searchPlaylists(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
}
|
||||
@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 {
|
||||
ensureEnabled();
|
||||
if (!capabilities.hasGetPlaylist)
|
||||
return false;
|
||||
return plugin.executeBoolean("source.isPlaylistUrl(${Json.encodeToString(url)})") ?: false;
|
||||
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isPlaylistUrl", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@JSOptional
|
||||
@JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user")
|
||||
@JSDocsParameter("url", "Url of playlist")
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith {
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith("getPlaylist") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSPlaylistDetails(plugin, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
|
||||
return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@@ -613,19 +692,24 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
|
||||
private fun <T> isBusyWith(handle: ()->T): T {
|
||||
private fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
try {
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
_busyAction = actionName;
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter--;
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun <T> isBusyWith(handle: ()->T): T {
|
||||
return isBusyWith("Unknown", handle);
|
||||
}
|
||||
|
||||
private fun announcePluginUnhandledException(method: String, ex: Throwable) {
|
||||
if(ex is PluginEngineException)
|
||||
@@ -642,10 +726,43 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
companion object {
|
||||
val TAG = "JSClient";
|
||||
private val _lock = Object();
|
||||
private var _docs: Map<String, String>? = null;
|
||||
|
||||
fun getMethodDocs(names: List<String>): Map<String, String>? {
|
||||
synchronized(_lock) {
|
||||
if(_docs == null) {
|
||||
val client = ManagedHttpClient();
|
||||
val docs = names
|
||||
.map { stringWithoutBrackets(it) }
|
||||
.distinct()
|
||||
.parallelStream()
|
||||
.map {
|
||||
val url = "https://github.com/futo-org/grayjay-android/blob/master/docs/source/${it}.md";
|
||||
val resp = client.head(url);
|
||||
if(resp.isOk)
|
||||
return@map Pair(it, url);
|
||||
else
|
||||
return@map null;
|
||||
}.asSequence()
|
||||
.filterNotNull()
|
||||
.toMap();
|
||||
_docs = docs;
|
||||
}
|
||||
return _docs;
|
||||
}
|
||||
}
|
||||
fun getMethodDocUrls(): Map<String, String>? {
|
||||
if(_docs != null)
|
||||
return _docs;
|
||||
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
|
||||
return getMethodDocs(methods.map { it.name });
|
||||
}
|
||||
|
||||
fun getJSDocs(): List<JSCallDocs> {
|
||||
val docs = mutableListOf<JSCallDocs>();
|
||||
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
|
||||
|
||||
for(method in methods.sortedBy { it.getAnnotation(JSDocs::class.java)?.order }) {
|
||||
val doc = method.getAnnotation(JSDocs::class.java);
|
||||
val parameters = method.kotlinFunction!!.findAnnotations<JSDocsParameter>();
|
||||
@@ -658,5 +775,12 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
private fun stringWithoutBrackets(name: String): String {
|
||||
val index = name.indexOf('(');
|
||||
if(index >= 0)
|
||||
return name.substring(0, index);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -11,4 +11,5 @@ class SourcePluginAuthConfig(
|
||||
val userAgent: String? = null,
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null
|
||||
) { }
|
||||
+47
-4
@@ -5,9 +5,8 @@ import com.futo.platformplayer.SignatureProvider
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class SourcePluginConfig(
|
||||
@@ -46,7 +45,9 @@ class SourcePluginConfig(
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
var allowAllHttpHeaderAccess: Boolean = false,
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
@@ -80,6 +81,44 @@ class SourcePluginConfig(
|
||||
return _allowUrlsLowerVal!!;
|
||||
};
|
||||
|
||||
fun isLowRiskUpdate(oldScript: String, newConfig: SourcePluginConfig, newScript: String): Boolean{
|
||||
//New allow header access
|
||||
if(!allowAllHttpHeaderAccess && newConfig.allowAllHttpHeaderAccess)
|
||||
return false;
|
||||
|
||||
//All urls should already be allowed
|
||||
for(url in newConfig.allowUrls) {
|
||||
if(!allowUrls.contains(url))
|
||||
return false;
|
||||
}
|
||||
//All packages should already be allowed
|
||||
for(pack in newConfig.packages) {
|
||||
if(!packages.contains(pack))
|
||||
return false;
|
||||
}
|
||||
//Developer Submit Url should be same or empty
|
||||
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
|
||||
return false;
|
||||
|
||||
//Should have a public key
|
||||
if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty())
|
||||
return false;
|
||||
|
||||
//Should be same public key
|
||||
if(scriptPublicKey != newConfig.scriptPublicKey)
|
||||
return false;
|
||||
|
||||
//Old signature should be valid
|
||||
if(!validate(oldScript))
|
||||
return false;
|
||||
|
||||
//New signature should be valid
|
||||
if(!newConfig.validate(newScript))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fun getWarnings(scriptToCheck: String? = null) : List<Pair<String,String>> {
|
||||
val list = mutableListOf<Pair<String,String>>();
|
||||
|
||||
@@ -108,6 +147,11 @@ class SourcePluginConfig(
|
||||
list.add(Pair(
|
||||
"Unrestricted Web Access",
|
||||
"This plugin requires access to all URLs, this may include malicious URLs."));
|
||||
if(allowAllHttpHeaderAccess)
|
||||
list.add(Pair(
|
||||
"Unrestricted Http Header access",
|
||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||
))
|
||||
|
||||
return list;
|
||||
}
|
||||
@@ -149,7 +193,6 @@ class SourcePluginConfig(
|
||||
val warningDialog: String? = null,
|
||||
val options: List<String>? = null
|
||||
) {
|
||||
@kotlinx.serialization.Transient
|
||||
val variableOrName: String get() = variable ?: name;
|
||||
}
|
||||
}
|
||||
+40
-7
@@ -2,10 +2,13 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -27,17 +30,19 @@ class SourcePluginDescriptor {
|
||||
@kotlinx.serialization.Transient
|
||||
val onCaptchaChanged = Event0();
|
||||
|
||||
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null) {
|
||||
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, settings: HashMap<String, String?>? = null) {
|
||||
this.config = config;
|
||||
this.authEncrypted = authEncrypted;
|
||||
this.captchaEncrypted = captchaEncrypted;
|
||||
this.flags = listOf();
|
||||
this.settings = settings ?: hashMapOf();
|
||||
}
|
||||
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>) {
|
||||
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>, settings: HashMap<String, String?>? = null) {
|
||||
this.config = config;
|
||||
this.authEncrypted = authEncrypted;
|
||||
this.captchaEncrypted = captchaEncrypted;
|
||||
this.flags = flags;
|
||||
this.settings = settings ?: hashMapOf();
|
||||
}
|
||||
|
||||
fun getSettingsWithDefaults(): HashMap<String, String?> {
|
||||
@@ -54,7 +59,16 @@ class SourcePluginDescriptor {
|
||||
onCaptchaChanged.emit();
|
||||
}
|
||||
fun getCaptchaData(): SourceCaptchaData? {
|
||||
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
|
||||
try {
|
||||
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SourcePluginDescriptor", "Captcha decode failed, disabling auth.", ex);
|
||||
StateAnnouncement.instance.registerAnnouncement("CAP_BROKEN_" + config.id,
|
||||
"Captcha corrupted for plugin [${config.name}]",
|
||||
"Something went wrong in the stored captcha, you'll have to login again", AnnouncementType.SESSION);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAuth(str: SourceAuth?) {
|
||||
@@ -62,12 +76,26 @@ class SourcePluginDescriptor {
|
||||
onAuthChanged.emit();
|
||||
}
|
||||
fun getAuth(): SourceAuth? {
|
||||
return SourceAuth.fromEncrypted(authEncrypted);
|
||||
try {
|
||||
return SourceAuth.fromEncrypted(authEncrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SourcePluginDescriptor", "Authentication decode failed, disabling auth.", ex);
|
||||
StateAnnouncement.instance.registerAnnouncement("AUTH_BROKEN_" + config.id,
|
||||
"Authentication corrupted for plugin [${config.name}]",
|
||||
"Something went wrong in the stored authentication, you'll have to login again", AnnouncementType.SESSION);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AppPluginSettings {
|
||||
|
||||
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
||||
var checkForUpdates: Boolean = true;
|
||||
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
||||
var automaticUpdate: Boolean = false;
|
||||
|
||||
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
||||
var tabEnabled = TabEnabled();
|
||||
@Serializable
|
||||
@@ -105,11 +133,16 @@ class SourcePluginDescriptor {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.allow_developer_submit, FieldForm.TOGGLE, R.string.allow_developer_submit_description, 1, "devSubmit")
|
||||
var allowDeveloperSubmit: Boolean = false;
|
||||
|
||||
|
||||
fun loadDefaults(config: SourcePluginConfig) {
|
||||
if(tabEnabled.enableHome == null)
|
||||
tabEnabled.enableHome = config.enableInHome ?: true;
|
||||
tabEnabled.enableHome = config.enableInHome
|
||||
if(tabEnabled.enableSearch == null)
|
||||
tabEnabled.enableSearch = config.enableInSearch ?: true;
|
||||
tabEnabled.enableSearch = config.enableInSearch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,6 @@ annotation class JSOptional()
|
||||
annotation class JSDocsParameter(val name: String, val description: String, val order: Int = 0)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false);
|
||||
data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false, val docsUrl: String? = null);
|
||||
@kotlinx.serialization.Serializable
|
||||
data class JSParameterDocs(val name: String, val description: String);
|
||||
+121
-73
@@ -1,14 +1,24 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.internal
|
||||
|
||||
import android.net.Uri
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
|
||||
import com.futo.platformplayer.developer.DeveloperEndpoints
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.google.common.net.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.GzipSource
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.UUID
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
@@ -16,20 +26,32 @@ class JSHttpClient : ManagedHttpClient {
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
val clientId = UUID.randomUUID().toString();
|
||||
|
||||
var doUpdateCookies: Boolean = true;
|
||||
var doApplyCookies: Boolean = true;
|
||||
var doAllowNewCookies: Boolean = true;
|
||||
val isLoggedIn: Boolean get() = _auth != null;
|
||||
|
||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
private var _otherCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super(
|
||||
//Temporary ugly solution for DevPortal proxy support
|
||||
(if(jsClient?.config?.id == StateDeveloper.DEV_ID && StateDeveloper.instance.devProxy != null)
|
||||
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.HTTP,
|
||||
InetSocketAddress(StateDeveloper.instance.devProxy!!.url, StateDeveloper.instance.devProxy!!.port)
|
||||
))
|
||||
else
|
||||
OkHttpClient.Builder())
|
||||
) {
|
||||
_jsClient = jsClient;
|
||||
_jsConfig = config;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
|
||||
_currentCookieMap = hashMapOf();
|
||||
_otherCookieMap = hashMapOf();
|
||||
if(!auth?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in auth!!.cookieMap!!)
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
@@ -47,13 +69,49 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
override fun clone(): ManagedHttpClient {
|
||||
val newClient = JSHttpClient(_jsClient, _auth);
|
||||
newClient._currentCookieMap = if(_currentCookieMap != null)
|
||||
HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
|
||||
else
|
||||
hashMapOf();
|
||||
newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
|
||||
return newClient;
|
||||
}
|
||||
|
||||
//TODO: Use this in beforeRequest to remove dup code
|
||||
fun applyHeaders(url: Uri, headers: MutableMap<String, String>, applyAuth: Boolean = false, applyOtherCookies: Boolean = false) {
|
||||
val domain = url.host!!.lowercase();
|
||||
val auth = _auth;
|
||||
if (applyAuth && auth != null) {
|
||||
//TODO: Possibly add doApplyHeaders
|
||||
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
|
||||
headers.put(header.key, header.value);
|
||||
}
|
||||
|
||||
if(doApplyCookies && (applyAuth || applyOtherCookies)) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
if(applyOtherCookies)
|
||||
synchronized(_otherCookieMap) {
|
||||
for(cookie in _otherCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
}
|
||||
if(applyAuth)
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
|
||||
val existingCookies = headers["Cookie"];
|
||||
if(!existingCookies.isNullOrEmpty())
|
||||
headers.put("Cookie", existingCookies.trim(';') + "; " + cookieString);
|
||||
else
|
||||
headers.put("Cookie", cookieString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
|
||||
val domain = request.url.host.lowercase();
|
||||
val auth = _auth;
|
||||
@@ -69,10 +127,10 @@ class JSHttpClient : ManagedHttpClient {
|
||||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (!_currentCookieMap.isNullOrEmpty()) {
|
||||
if (_currentCookieMap.isNotEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap!!) {
|
||||
for(cookie in _currentCookieMap!!
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
@@ -92,11 +150,11 @@ class JSHttpClient : ManagedHttpClient {
|
||||
}
|
||||
|
||||
if(_jsClient != null)
|
||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||
_jsClient.validateUrlOrThrow(request.url.toString());
|
||||
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
|
||||
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
|
||||
|
||||
return newBuilder?.let { it.build() } ?: request;
|
||||
return newBuilder?.build() ?: request;
|
||||
}
|
||||
|
||||
override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
|
||||
@@ -106,85 +164,75 @@ class JSHttpClient : ManagedHttpClient {
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in resp.headers) {
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
|
||||
//val newCookies = cookieStringToMap(header.second.split("; "));
|
||||
if(header.first.lowercase() == "set-cookie") {
|
||||
var domainToUse = domain;
|
||||
val cookie = cookieStringToPair(header.second);
|
||||
//for (cookie in newCookies) {
|
||||
var cookieValue = cookie.second;
|
||||
var domainToUse = domain;
|
||||
var cookieValue = cookie.second;
|
||||
|
||||
if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
|
||||
val cookieVariables = cookieParts.drop(1).map {
|
||||
val splitIndex = it.indexOf("=");
|
||||
if (splitIndex < 0)
|
||||
return@map Pair(it.trim().lowercase(), "");
|
||||
return@map Pair<String, String>(
|
||||
it.substring(0, splitIndex).lowercase().trim(),
|
||||
it.substring(splitIndex + 1).trim()
|
||||
);
|
||||
}.toMap();
|
||||
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||
cookieVariables["domain"]!!.lowercase();
|
||||
else defaultCookieDomain;
|
||||
}
|
||||
val cookieVariables = cookieParts.drop(1).map {
|
||||
val splitIndex = it.indexOf("=");
|
||||
if (splitIndex < 0)
|
||||
return@map Pair(it.trim().lowercase(), "");
|
||||
return@map Pair<String, String>(
|
||||
it.substring(0, splitIndex).lowercase().trim(),
|
||||
it.substring(splitIndex + 1).trim()
|
||||
);
|
||||
}.toMap();
|
||||
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||
cookieVariables["domain"]!!.lowercase();
|
||||
else defaultCookieDomain;
|
||||
//TODO: Make sure this has no negative effect besides apply cookies to root domain
|
||||
if(!domainToUse.startsWith("."))
|
||||
domainToUse = ".${domainToUse}";
|
||||
}
|
||||
|
||||
val cookieMap = if (_currentCookieMap!!.containsKey(domainToUse))
|
||||
_currentCookieMap!![domainToUse]!!;
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
|
||||
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
|
||||
_currentCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_currentCookieMap!!.put(domainToUse, newMap)
|
||||
_currentCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap.put(cookie.first, cookieValue);
|
||||
//}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
else {
|
||||
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
|
||||
_otherCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_otherCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(_jsClient is DevJSClient) {
|
||||
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||
StateDeveloper.instance.addDevHttpExchange(
|
||||
StateDeveloper.DevHttpExchange(
|
||||
StateDeveloper.DevHttpRequest(resp.request.method, resp.request.url.toString(), mapOf(*resp.request.headers.map { Pair(it.first, it.second) }.toTypedArray()), ""),
|
||||
StateDeveloper.DevHttpRequest("RESP", resp.request.url.toString(), mapOf(*resp.headers.map { Pair(it.first, it.second) }.toTypedArray()), "", resp.code)
|
||||
));
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
private fun cookieStringToMap(parts: List<String>): Map<String, String> {
|
||||
val map = hashMapOf<String, String>();
|
||||
for(cookie in parts) {
|
||||
val pair = cookieStringToPair(cookie)
|
||||
map.put(pair.first, pair.second);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
|
||||
return Pair(cookieKey.trim(), cookieVal.trim());
|
||||
}
|
||||
|
||||
//Prints out code for test reproduction..
|
||||
fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) {
|
||||
var code = "Code: \n";
|
||||
code += "\nurl = \"${url}\";";
|
||||
if(body != null)
|
||||
code += "\nbody = \"${String(body).replace("\"", "\\\"")}\";";
|
||||
if(headers != null)
|
||||
for(header in headers) {
|
||||
code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");";
|
||||
}
|
||||
if(cookieString != null)
|
||||
code += "\nclient.Headers.Add(\"Cookie\", \"${cookieString}\");";
|
||||
|
||||
if(allHeaders != null) {
|
||||
code += "\n//OTHER HEADERS:"
|
||||
for (header in allHeaders) {
|
||||
code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");";
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i("Testing", code);
|
||||
}
|
||||
|
||||
}
|
||||
+4
-2
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
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.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
@@ -10,13 +11,14 @@ import com.futo.platformplayer.getOrThrow
|
||||
interface IJSContent: IPlatformContent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContent {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent {
|
||||
val config = plugin.config;
|
||||
val type: Int = obj.getOrThrow(config, "contentType", "ContentItem");
|
||||
val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null);
|
||||
|
||||
//TODO: Temporary workaround for intercepting details in lists
|
||||
if(pluginType != null && pluginType.endsWith("Details"))
|
||||
return IJSContentDetails.fromV8(config, obj);
|
||||
return IJSContentDetails.fromV8(plugin, obj);
|
||||
|
||||
return when(ContentType.fromInt(type)) {
|
||||
ContentType.MEDIA -> JSVideo(config, obj);
|
||||
|
||||
+5
-4
@@ -4,17 +4,18 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IJSContentDetails: IPlatformContent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContentDetails {
|
||||
val type: Int = obj.getOrThrow(config, "contentType", "ContentDetails");
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails {
|
||||
val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails");
|
||||
return when(ContentType.fromInt(type)) {
|
||||
ContentType.MEDIA -> JSVideoDetails(config, obj);
|
||||
ContentType.POST -> JSPostDetails(config, obj);
|
||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,13 +2,14 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): PlatformAuthorLink {
|
||||
return PlatformAuthorLink.fromV8(config, obj);
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -60,6 +61,7 @@ class JSComment : IPlatformComment {
|
||||
return null;
|
||||
|
||||
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
return JSCommentPager(_config!!, _plugin!!, obj);
|
||||
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
||||
return JSCommentPager(_config!!, plugin, obj);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -2,15 +2,16 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSCommentPager : JSPager<IPlatformComment>, IPager<IPlatformComment> {
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) { }
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) { }
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformComment {
|
||||
return JSComment(config, plugin, obj);
|
||||
return JSComment(config, plugin.getUnderlyingPlugin(), obj);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -3,15 +3,16 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
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.engine.V8Plugin
|
||||
|
||||
class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||
override val sourceConfig: SourcePluginConfig get() = config;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||
return IJSContent.fromV8(config, obj);
|
||||
return IJSContent.fromV8(plugin, obj);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -10,7 +11,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
class JSLiveEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
override var nextRequest: Int;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Looper
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -12,7 +13,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
abstract class JSPager<T> : IPager<T> {
|
||||
protected val plugin: V8Plugin;
|
||||
protected val plugin: JSClient;
|
||||
protected val config: SourcePluginConfig;
|
||||
protected var pager: V8ValueObject;
|
||||
|
||||
@@ -21,9 +22,9 @@ abstract class JSPager<T> : IPager<T> {
|
||||
private var _hasMorePages: Boolean = false;
|
||||
//private var _morePagesWasFalse: Boolean = false;
|
||||
|
||||
val isAvailable get() = plugin._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) {
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) {
|
||||
this.plugin = plugin;
|
||||
this.pager = pager;
|
||||
this.config = config;
|
||||
@@ -43,7 +44,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
override fun nextPage() {
|
||||
warnIfMainThread("JSPager.nextPage");
|
||||
|
||||
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
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.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -13,7 +14,7 @@ import com.futo.platformplayer.models.Playlist
|
||||
class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
|
||||
override val contents: IPager<IPlatformVideo>;
|
||||
|
||||
constructor(plugin: V8Plugin, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
constructor(plugin: JSClient, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
contents = JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails"));
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -2,13 +2,14 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSPlaylistPager : JSPager<IPlatformPlaylist>, IPager<IPlatformPlaylist> {
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformPlaylist {
|
||||
return JSPlaylist(config, obj);
|
||||
|
||||
+1
-1
@@ -54,6 +54,6 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager);
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
+89
-8
@@ -1,18 +1,99 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import android.net.Uri
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.modifier.IModifierOptions
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class JSRequest : JSRequestModifier.IRequest {
|
||||
override val url: String;
|
||||
override val headers: Map<String, String>;
|
||||
class JSRequest : IRequest {
|
||||
private val _v8Url: String?;
|
||||
private val _v8Headers: Map<String, String>?;
|
||||
private val _v8Options: Options?;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
override var url: String? = null;
|
||||
override lateinit var headers: Map<String, String>;
|
||||
|
||||
constructor(plugin: JSClient, url: String?, headers: Map<String, String>?, options: Options?, originalUrl: String?, originalHeaders: Map<String, String>?) {
|
||||
_v8Url = url;
|
||||
_v8Headers = headers;
|
||||
_v8Options = options;
|
||||
initialize(plugin, originalUrl, originalHeaders);
|
||||
}
|
||||
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
|
||||
val contextName = "ModifyRequestResponse";
|
||||
url = obj.getOrThrow(config, "url", contextName);
|
||||
headers = obj.getOrThrow(config, "headers", contextName);
|
||||
val config = plugin.config;
|
||||
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
|
||||
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
|
||||
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
|
||||
Options(config, it, applyOtherHeadersByDefault);
|
||||
} ?: Options(null, null, applyOtherHeadersByDefault);
|
||||
initialize(plugin, originalUrl, originalHeaders);
|
||||
}
|
||||
|
||||
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
|
||||
val config = plugin.config;
|
||||
url = _v8Url ?: originalUrl;
|
||||
|
||||
if(_v8Options?.applyOtherHeaders ?: false) {
|
||||
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
|
||||
if (originalHeaders != null)
|
||||
for (head in originalHeaders)
|
||||
if (!headersToSet.containsKey(head.key))
|
||||
headersToSet[head.key] = head.value;
|
||||
headers = headersToSet;
|
||||
}
|
||||
else
|
||||
headers = _v8Headers ?: originalHeaders ?: mapOf();
|
||||
|
||||
if(_v8Options != null) {
|
||||
if(_v8Options.applyCookieClient != null && url != null) {
|
||||
val client = plugin.getHttpClientById(_v8Options.applyCookieClient);
|
||||
if(client != null) {
|
||||
val toModifyHeaders = headers.toMutableMap();
|
||||
client.applyHeaders(Uri.parse(url), toModifyHeaders, false, true);
|
||||
headers = toModifyHeaders;
|
||||
}
|
||||
}
|
||||
if(_v8Options.applyAuthClient != null && url != null) {
|
||||
val client = plugin.getHttpClientById(_v8Options.applyAuthClient);
|
||||
if(client != null) {
|
||||
val toModifyHeaders = headers.toMutableMap();
|
||||
client.applyHeaders(Uri.parse(url), toModifyHeaders, true, false);
|
||||
headers = toModifyHeaders;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun modify(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?): JSRequest {
|
||||
return JSRequest(plugin, _v8Url, _v8Headers, _v8Options, originalUrl, originalHeaders);
|
||||
}
|
||||
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class Options: IModifierOptions {
|
||||
override val applyAuthClient: String?;
|
||||
override val applyCookieClient: String?;
|
||||
override val applyOtherHeaders: Boolean;
|
||||
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject, applyOtherHeadersByDefault: Boolean = false) {
|
||||
applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null);
|
||||
applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null);
|
||||
applyOtherHeaders = obj.getOrDefault(config, "applyOtherHeaders", "JSRequestModifier.options.applyOtherHeaders", applyOtherHeadersByDefault) ?: applyOtherHeadersByDefault;
|
||||
}
|
||||
constructor(applyAuthClient: String? = null, applyCookieClient: String? = null, applyOtherHeaders: Boolean = false) {
|
||||
this.applyAuthClient = applyAuthClient;
|
||||
this.applyCookieClient = applyCookieClient;
|
||||
this.applyOtherHeaders = applyOtherHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -1,19 +1,28 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import android.net.Uri
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSRequestModifier {
|
||||
class JSRequestModifier: IRequestModifier {
|
||||
private val _plugin: JSClient;
|
||||
private val _config: IV8PluginConfig;
|
||||
private var _modifier: V8ValueObject;
|
||||
val allowByteSkip: Boolean;
|
||||
override var allowByteSkip: Boolean;
|
||||
|
||||
constructor(config: IV8PluginConfig, modifier: V8ValueObject) {
|
||||
constructor(plugin: JSClient, modifier: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
this._modifier = modifier;
|
||||
this._config = config;
|
||||
this._config = plugin.config;
|
||||
val config = plugin.config;
|
||||
|
||||
allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true;
|
||||
|
||||
@@ -21,22 +30,20 @@ class JSRequestModifier {
|
||||
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
|
||||
}
|
||||
|
||||
fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||
if (_modifier.isClosed) {
|
||||
return Request(url, headers);
|
||||
}
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
};
|
||||
} as V8ValueObject;
|
||||
|
||||
return JSRequest(_config, result as V8ValueObject);
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return req;
|
||||
}
|
||||
|
||||
interface IRequest {
|
||||
val url: String;
|
||||
val headers: Map<String, String>;
|
||||
}
|
||||
|
||||
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
|
||||
}
|
||||
+7
-6
@@ -44,13 +44,14 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
override val subtitles: List<ISubtitleSource>;
|
||||
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
val config = plugin.config;
|
||||
description = _content.getOrThrow(config, "description", contextName);
|
||||
video = JSVideoSourceDescriptor.fromV8(config, _content.getOrThrow(config, "video", contextName));
|
||||
dash = JSSource.fromV8DashNullable(config, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
|
||||
hls = JSSource.fromV8HLSNullable(config, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
|
||||
live = JSSource.fromV8VideoNullable(config, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
|
||||
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
|
||||
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
|
||||
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
|
||||
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
|
||||
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
|
||||
|
||||
if(!_content.has("subtitles"))
|
||||
@@ -105,6 +106,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return null;
|
||||
|
||||
return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager);
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,12 +2,13 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSVideoPager : JSPager<IPlatformVideo> {
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformVideo {
|
||||
return JSVideo(config, obj);
|
||||
|
||||
+4
-1
@@ -2,7 +2,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
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.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -19,8 +21,9 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_AUDIOURL, config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
||||
val contextName = "AudioUrlSource";
|
||||
val config = plugin.config;
|
||||
|
||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
||||
container = _obj.getOrThrow(config, "container", contextName);
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
override val bearerToken: String
|
||||
override val licenseUri: String
|
||||
|
||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
|
||||
val contextName = "JSAudioUrlWidevineSource"
|
||||
val config = plugin.config
|
||||
bearerToken = _obj.getOrThrow(config, "bearerToken", contextName)
|
||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val url = getAudioUrl()
|
||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, bearerToken=$bearerToken, licenseUri=$licenseUri)"
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -3,7 +3,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
|
||||
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.getOrDefault
|
||||
|
||||
class JSAudioUrlRangeSource : JSAudioUrlSource, IStreamMetaDataSource {
|
||||
@@ -22,8 +24,9 @@ class JSAudioUrlRangeSource : JSAudioUrlSource, IStreamMetaDataSource {
|
||||
&& indexEnd != null)
|
||||
StreamMetaData(initStart, initEnd, indexStart, indexEnd) else null;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
|
||||
val contextName = "JSAudioUrlRangeSource";
|
||||
val config = plugin.config;
|
||||
|
||||
itagId = _obj.getOrDefault(config, "itagId", contextName, null);
|
||||
initStart = _obj.getOrDefault(config, "initStart", contextName, null);
|
||||
|
||||
+4
-2
@@ -3,7 +3,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
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.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -19,9 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_DASH, config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||
val contextName = "DashSource";
|
||||
|
||||
val config = plugin.config;
|
||||
name = _obj.getOrThrow(config, "name", contextName);
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||
|
||||
+6
-3
@@ -4,7 +4,9 @@ import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
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.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orNull
|
||||
@@ -20,8 +22,9 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_HLS, config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||
val contextName = "HLSAudioSource";
|
||||
val config = plugin.config;
|
||||
|
||||
name = _obj.getOrThrow(config, "name", contextName);
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
@@ -33,7 +36,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) };
|
||||
fun fromV8HLS(config: IV8PluginConfig, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(config, obj);
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(plugin, obj);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -3,7 +3,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
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.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -19,8 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_HLS, config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||
val contextName = "HLSSource";
|
||||
val config = plugin.config;
|
||||
|
||||
name = _obj.getOrThrow(config, "name", contextName);
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
|
||||
+41
-32
@@ -1,34 +1,50 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
|
||||
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.getOrDefault
|
||||
import com.futo.platformplayer.orNull
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
|
||||
abstract class JSSource {
|
||||
protected val _plugin: JSClient;
|
||||
protected val _config: IV8PluginConfig;
|
||||
protected val _obj: V8ValueObject;
|
||||
private val _hasRequestModifier: Boolean;
|
||||
val hasRequestModifier: Boolean;
|
||||
private val _requestModifier: JSRequest?;
|
||||
|
||||
val type : String;
|
||||
|
||||
constructor(type: String, config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
this._config = config;
|
||||
constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
this._config = plugin.config;
|
||||
this._obj = obj;
|
||||
this.type = type;
|
||||
|
||||
_hasRequestModifier = obj.has("getRequestModifier");
|
||||
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
|
||||
JSRequest(plugin, it, null, null, true);
|
||||
}
|
||||
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
|
||||
}
|
||||
|
||||
fun getRequestModifier(): JSRequestModifier? {
|
||||
if (!_hasRequestModifier || _obj.isClosed) {
|
||||
fun getRequestModifier(): IRequestModifier? {
|
||||
if(_requestModifier != null)
|
||||
return AdhocRequestModifier { url, headers ->
|
||||
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
||||
};
|
||||
|
||||
if (!hasRequestModifier || _obj.isClosed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,16 +56,7 @@ abstract class JSSource {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSRequestModifier(_config, result)
|
||||
}
|
||||
|
||||
fun getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||
val requestModifier = getRequestModifier();
|
||||
return if (requestModifier != null) {
|
||||
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory();
|
||||
}
|
||||
return JSRequestModifier(_plugin, result)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -59,29 +66,31 @@ abstract class JSSource {
|
||||
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
|
||||
const val TYPE_DASH = "DashSource";
|
||||
const val TYPE_HLS = "HLSSource";
|
||||
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
||||
|
||||
fun fromV8VideoNullable(config: IV8PluginConfig, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(config, it as V8ValueObject) };
|
||||
fun fromV8Video(config: IV8PluginConfig, obj: V8ValueObject) : IVideoSource {
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
|
||||
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource {
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_VIDEOURL -> JSVideoUrlSource(config, obj);
|
||||
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(config, obj);
|
||||
TYPE_HLS -> fromV8HLS(config, obj);
|
||||
TYPE_DASH -> fromV8Dash(config, obj);
|
||||
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
|
||||
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
|
||||
TYPE_HLS -> fromV8HLS(plugin, obj);
|
||||
TYPE_DASH -> fromV8Dash(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown type ${type}");
|
||||
}
|
||||
}
|
||||
fun fromV8DashNullable(config: IV8PluginConfig, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(config, it as V8ValueObject) };
|
||||
fun fromV8Dash(config: IV8PluginConfig, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(config, obj);
|
||||
fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) };
|
||||
fun fromV8HLS(config: IV8PluginConfig, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(config, obj);
|
||||
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 fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
|
||||
|
||||
fun fromV8Audio(config: IV8PluginConfig, obj: V8ValueObject) : IAudioSource {
|
||||
fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource {
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(config, obj);
|
||||
TYPE_AUDIOURL -> JSAudioUrlSource(config, obj);
|
||||
TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(config, obj);
|
||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
||||
TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
|
||||
TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
|
||||
TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -15,15 +16,16 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
override val audioSources: Array<IAudioSource>;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) {
|
||||
this._obj = obj;
|
||||
val config = plugin.config;
|
||||
val contextName = "UnMuxVideoSource"
|
||||
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Video(config, it as V8ValueObject) }
|
||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||
.toTypedArray();
|
||||
this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Audio(config, it as V8ValueObject) }
|
||||
.map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
|
||||
.toTypedArray();
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -5,21 +5,22 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
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.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
||||
protected val _obj: V8ValueObject;
|
||||
|
||||
override val isUnMuxed: Boolean;
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) {
|
||||
this._obj = obj;
|
||||
val config = plugin.config;
|
||||
val contextName = "VideoSourceDescriptor";
|
||||
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
|
||||
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
|
||||
.map { JSSource.fromV8Video(config, it as V8ValueObject) }
|
||||
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
|
||||
.toTypedArray();
|
||||
}
|
||||
|
||||
@@ -28,11 +29,11 @@ class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
const val TYPE_UNMUXED = "UnMuxVideoSourceDescriptor";
|
||||
|
||||
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : IVideoSourceDescriptor {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject) : IVideoSourceDescriptor {
|
||||
val type = obj.getString("plugin_type")
|
||||
return when(type) {
|
||||
TYPE_MUXED -> JSVideoSourceDescriptor(config, obj);
|
||||
TYPE_UNMUXED -> JSUnMuxVideoSourceDescriptor(config, obj);
|
||||
TYPE_MUXED -> JSVideoSourceDescriptor(plugin, obj);
|
||||
TYPE_UNMUXED -> JSUnMuxVideoSourceDescriptor(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown type: ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,7 +2,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
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.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@@ -18,8 +20,9 @@ open class JSVideoUrlSource : IVideoUrlSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject): super(TYPE_VIDEOURL, config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
|
||||
val contextName = "JSVideoUrlSource";
|
||||
val config = plugin.config;
|
||||
|
||||
width = _obj.getOrThrow(config, "width", contextName);
|
||||
height = _obj.getOrThrow(config, "height", contextName);
|
||||
|
||||
+4
-1
@@ -3,7 +3,9 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
|
||||
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.getOrDefault
|
||||
|
||||
class JSVideoUrlRangeSource : JSVideoUrlSource, IStreamMetaDataSource {
|
||||
@@ -21,8 +23,9 @@ class JSVideoUrlRangeSource : JSVideoUrlSource, IStreamMetaDataSource {
|
||||
&& indexEnd != null)
|
||||
StreamMetaData(initStart, initEnd, indexStart, indexEnd) else null;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
|
||||
val contextName = "JSVideoUrlRangeSource";
|
||||
val config = plugin.config;
|
||||
|
||||
itagId = _obj.getOrDefault(config, "itagId", contextName, null);
|
||||
initStart = _obj.getOrDefault(config, "initStart", contextName, null);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.futo.platformplayer.api.media.structures
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* A Pager interface that implements a suspended manner of nextPage
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.futo.platformplayer.api.media.structures
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asRe
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
|
||||
|
||||
private val _pending: MutableList<Deferred<IPager<T>?>>;
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
constructor(pagers: List<IPager<T>>, pendingPagers: List<Deferred<IPager<T>?>>, placeholderPagers: List<IPager<T>>? = null) {
|
||||
_pagersReusable = pagers.map { ReusablePager(it) }.toMutableList();
|
||||
_totalPagers = pagers.size + pendingPagers.size;
|
||||
@@ -100,7 +102,7 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
|
||||
}
|
||||
|
||||
private fun getCurrentSubPagers(): List<IPager<T>> {
|
||||
val reusableWindows = _pagersReusable.map { it.getWindow() as IPager<T> };
|
||||
val reusableWindows = _pagersReusable.map { it.getWindow() };
|
||||
val placeholderWindows = synchronized(_pending) {
|
||||
_placeHolderPagersPaired.filter { _pending.contains(it.key) }.values
|
||||
}
|
||||
|
||||
+3
-3
@@ -41,7 +41,7 @@ class SingleAsyncItemPager<T> {
|
||||
fun getCurrentItem(scope: CoroutineScope) : Deferred<T?>? {
|
||||
synchronized(_requestedPageItems) {
|
||||
if (_currentResultPos >= _requestedPageItems.size) {
|
||||
val startPos = fillDeferredUntil(_currentResultPos);
|
||||
fillDeferredUntil(_currentResultPos);
|
||||
if(!_pager.hasMorePages()) {
|
||||
Logger.i("SingleAsyncItemPager", "end of async page reached");
|
||||
completeRemainder { it?.complete(null) };
|
||||
@@ -49,7 +49,7 @@ class SingleAsyncItemPager<T> {
|
||||
if(_isRequesting)
|
||||
return _requestedPageItems[_currentResultPos];
|
||||
_isRequesting = true;
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i("SingleAsyncItemPager", "Started Pager");
|
||||
val timeForPage = measureTimeMillis { _pager.nextPage() };
|
||||
@@ -100,7 +100,7 @@ class SingleAsyncItemPager<T> {
|
||||
|
||||
private fun fillDeferredUntil(i: Int): Int {
|
||||
val startPos = _requestedPageItems.size;
|
||||
for(i in _requestedPageItems.size..i) {
|
||||
for(v in _requestedPageItems.size..i) {
|
||||
_requestedPageItems.add(CompletableDeferred());
|
||||
}
|
||||
return startPos;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
@@ -18,7 +23,7 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = false;
|
||||
override val canSetSpeed: Boolean get() = false; //TODO: Implement playback speed for AirPlay
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
@@ -51,7 +56,8 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
time = resumePosition;
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
if (resumePosition > 0.0) {
|
||||
val pos = resumePosition / duration;
|
||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
||||
@@ -59,6 +65,10 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
} else {
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
||||
}
|
||||
|
||||
if (speed != null) {
|
||||
changeSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
@@ -128,7 +138,7 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
delay(3000);
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -153,16 +163,25 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
delay(1000);
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
|
||||
time = progress;
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -186,6 +205,11 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
_scopeIO = null;
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -11,7 +10,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.net.InetAddress
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
@@ -59,36 +57,58 @@ abstract class CastingDevice {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
var timeReceivedAt: OffsetDateTime = OffsetDateTime.now()
|
||||
private set;
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
timeReceivedAt = OffsetDateTime.now();
|
||||
onTimeChanged.emit(value);
|
||||
}
|
||||
};
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onVolumeChanged.emit(value);
|
||||
}
|
||||
};
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
speed = value;
|
||||
if (changed) {
|
||||
onSpeedChanged.emit(value);
|
||||
}
|
||||
};
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff = timeReceivedAt.getNowDiffMiliseconds().toDouble() / 1000.0;
|
||||
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
@@ -104,6 +124,7 @@ abstract class CastingDevice {
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.protos.ChromeCast
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
@@ -38,7 +44,9 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
private var _socket: SSLSocket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _outputStreamLock = Object();
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _inputStreamLock = Object();
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _requestId = 1;
|
||||
private var _started: Boolean = false;
|
||||
@@ -46,6 +54,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
private var _transportId: String? = null;
|
||||
private var _launching = false;
|
||||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -70,7 +80,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
time = resumePosition;
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
_streamType = streamType;
|
||||
_contentType = contentType;
|
||||
_contentId = contentId;
|
||||
@@ -132,7 +143,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
this.volume = volume
|
||||
setVolume(volume)
|
||||
val setVolumeObject = JSONObject();
|
||||
setVolumeObject.put("type", "SET_VOLUME");
|
||||
|
||||
@@ -265,7 +276,6 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
@@ -278,152 +288,183 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
_launching = true;
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
ensureThreadsStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
fun ensureThreadsStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
}
|
||||
}
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (resultSocket == null) {
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
}
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.start();
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
//Start ping loop
|
||||
Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
Thread.sleep(5000);
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
_socket?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.")
|
||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.")
|
||||
val s = Socket().apply { this.connect(address, 2000) }
|
||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
||||
}
|
||||
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(409600);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size =
|
||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.start();
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() };
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
//Start ping loop
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.apply { start() };
|
||||
} else {
|
||||
Log.i(TAG, "Threads still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||
@@ -486,7 +527,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
time = 0.0;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
|
||||
@@ -502,11 +543,11 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
val volumeControlType = volume.getString("controlType");
|
||||
//val volumeControlType = volume.getString("controlType");
|
||||
val volumeLevel = volume.getString("level").toDouble();
|
||||
val volumeMuted = volume.getBoolean("muted");
|
||||
val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
||||
this.volume = if (volumeMuted) 0.0 else volumeLevel;
|
||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
||||
|
||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
||||
} else if (type == "MEDIA_STATUS") {
|
||||
@@ -517,10 +558,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
val playerState = status.getString("playerState");
|
||||
val currentTime = status.getDouble("currentTime");
|
||||
if (status.has("media")) {
|
||||
val media = status.getJSONObject("media")
|
||||
if (media.has("duration")) {
|
||||
setDuration(media.getDouble("duration"))
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying) {
|
||||
time = currentTime;
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
val playbackRate = status.getInt("playbackRate");
|
||||
@@ -548,13 +595,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
synchronized(_outputStreamLock)
|
||||
{
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
||||
}
|
||||
@@ -582,6 +632,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_pingThread = null;
|
||||
_thread = null;
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
|
||||
@@ -1,37 +1,72 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.spec.DHParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
enum class Opcode(val value: Byte) {
|
||||
NONE(0),
|
||||
PLAY(1),
|
||||
PAUSE(2),
|
||||
RESUME(3),
|
||||
STOP(4),
|
||||
SEEK(5),
|
||||
PLAYBACK_UPDATE(6),
|
||||
VOLUME_UPDATE(7),
|
||||
SET_VOLUME(8),
|
||||
PLAYBACK_ERROR(9),
|
||||
SET_SPEED(10),
|
||||
VERSION(11)
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8),
|
||||
PlaybackError(9),
|
||||
SetSpeed(10),
|
||||
Version(11),
|
||||
Ping(12),
|
||||
Pong(13);
|
||||
|
||||
companion object {
|
||||
private val _map = entries.associateBy { it.value }
|
||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
@@ -48,11 +83,15 @@ class FCastCastingDevice : CastingDevice {
|
||||
var port: Int = 0;
|
||||
|
||||
private var _socket: Socket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _outputStream: OutputStream? = null;
|
||||
private var _inputStream: InputStream? = null;
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
private var _lastPongTime = -1L
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -82,13 +121,16 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
time = resumePosition;
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
@@ -103,13 +145,16 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
time = resumePosition;
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
@@ -117,17 +162,17 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
this.volume = volume
|
||||
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
|
||||
setVolume(volume);
|
||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeSpeed(volume) })) {
|
||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.speed = speed
|
||||
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(volume))
|
||||
setSpeed(speed);
|
||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
@@ -135,7 +180,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.SEEK, FCastSeekMessage(
|
||||
send(Opcode.Seek, FCastSeekMessage(
|
||||
time = timeSeconds
|
||||
));
|
||||
}
|
||||
@@ -145,7 +190,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.RESUME);
|
||||
send(Opcode.Resume);
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
@@ -153,7 +198,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.PAUSE);
|
||||
send(Opcode.Pause);
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
@@ -161,12 +206,18 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.STOP);
|
||||
send(Opcode.Stop);
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
action();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -185,7 +236,6 @@ class FCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
@@ -193,139 +243,229 @@ class FCastCastingDevice : CastingDevice {
|
||||
_started = true;
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket = Socket(usedRemoteAddress, port);
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to FastCast.", e);
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.values().first { it.value == opcode }, json);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.start();
|
||||
|
||||
ensureThreadStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
fun ensureThreadStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
||||
|
||||
_scopeIO?.let {
|
||||
it.cancel()
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
}
|
||||
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Log.i(TAG, "Connection thread started.")
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
||||
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
|
||||
if (resultSocket == null) {
|
||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connection succeeded.")
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.");
|
||||
_socket = connectedSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.");
|
||||
_socket = Socket().apply { this.connect(address, 2000) };
|
||||
}
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
_outputStream = _socket?.outputStream;
|
||||
_inputStream = _socket?.inputStream;
|
||||
} catch (e: IOException) {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_lastPongTime = -1L
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
|
||||
var headerBytesRead = 0
|
||||
while (headerBytesRead < 4) {
|
||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
var bytesRead = 0
|
||||
while (bytesRead < size) {
|
||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
bytesRead += read
|
||||
}
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.find(opcode), json);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e)
|
||||
break
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() }
|
||||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
|
||||
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}*/
|
||||
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PLAYBACK_UPDATE -> {
|
||||
Opcode.PlaybackUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
||||
time = playbackUpdate.time;
|
||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
||||
isPlaying = when (playbackUpdate.state) {
|
||||
1 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Opcode.VOLUME_UPDATE -> {
|
||||
Opcode.VolumeUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||
volume = volumeUpdate.volume;
|
||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
||||
}
|
||||
Opcode.PLAYBACK_ERROR -> {
|
||||
Opcode.PlaybackError -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
||||
return;
|
||||
@@ -334,7 +474,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
||||
}
|
||||
Opcode.VERSION -> {
|
||||
Opcode.Version -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
||||
return;
|
||||
@@ -344,72 +484,54 @@ class FCastCastingDevice : CastingDevice {
|
||||
_version = version.version;
|
||||
Logger.i(TAG, "Remote version received: $version")
|
||||
}
|
||||
Opcode.Ping -> send(Opcode.Pong)
|
||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(opcode: Opcode) {
|
||||
try {
|
||||
val size = 1;
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
|
||||
return;
|
||||
private fun send(opcode: Opcode, message: String? = null) {
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized (_outputStreamLock) {
|
||||
try {
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4);
|
||||
serializedSizeLE[0] = (size and 0xff).toByte();
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
|
||||
outputStream.write(serializedSizeLE);
|
||||
|
||||
val opcodeBytes = ByteArray(1);
|
||||
opcodeBytes[0] = opcode.value;
|
||||
outputStream.write(opcodeBytes);
|
||||
|
||||
Log.d(TAG, "Sent $size bytes.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send message.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> sendMessage(opcode: Opcode, message: T) {
|
||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
||||
try {
|
||||
val data: ByteArray;
|
||||
var jsonString: String? = null;
|
||||
if (message != null) {
|
||||
jsonString = json.encodeToString(message);
|
||||
data = jsonString.encodeToByteArray();
|
||||
} else {
|
||||
data = ByteArray(0);
|
||||
}
|
||||
|
||||
val size = 1 + data.size;
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4);
|
||||
serializedSizeLE[0] = (size and 0xff).toByte();
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
|
||||
outputStream.write(serializedSizeLE);
|
||||
|
||||
val opcodeBytes = ByteArray(1);
|
||||
opcodeBytes[0] = opcode.value;
|
||||
outputStream.write(opcodeBytes);
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: '$jsonString'.");
|
||||
send(opcode, message?.let { Json.encodeToString(it) })
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send message.", e);
|
||||
Log.i(TAG, "Failed to encode message to string.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +540,9 @@ class FCastCastingDevice : CastingDevice {
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
//TODO: Kill and/or join thread?
|
||||
_thread = null;
|
||||
_pingThread = null;
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
@@ -427,6 +552,8 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
@@ -448,7 +575,65 @@ class FCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "FastCastCastingDevice";
|
||||
val TAG = "FCastCastingDevice";
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
fun generateKeyPair(): KeyPair {
|
||||
//modp14
|
||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
||||
val g = BigInteger("2", 16)
|
||||
val dhSpec = DHParameterSpec(p, g)
|
||||
|
||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
||||
keyGen.initialize(dhSpec)
|
||||
|
||||
return keyGen.generateKeyPair()
|
||||
}
|
||||
|
||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
||||
|
||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
||||
keyAgreement.init(privateKey)
|
||||
keyAgreement.doPhase(receivedPublicKey, true)
|
||||
|
||||
val sharedSecret = keyAgreement.generateSecret()
|
||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val hashedSecret = sha256.digest(sharedSecret)
|
||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
||||
|
||||
return SecretKeySpec(hashedSecret, "AES")
|
||||
}
|
||||
|
||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
||||
val iv = cipher.iv
|
||||
val json = Json.encodeToString(decryptedMessage)
|
||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
||||
return FCastEncryptedMessage(
|
||||
version = 1,
|
||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
val decryptedJson = cipher.doFinal(encrypted)
|
||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user