Compare commits

..

5 Commits

Author SHA1 Message Date
Koen J f0569cc7f5 Further work. 2025-12-05 10:24:23 +01:00
Koen J 618aee9c2c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into sabr 2025-12-04 11:30:21 +01:00
Koen J 2142bcba25 Potential fix for issue where cast icon doesn't properly turn blue at the right moment. 2025-12-03 12:04:29 +01:00
Koen J f7a07c1fcd Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into sabr 2025-12-02 20:07:05 +01:00
Koen J 3b359ad4a7 SABR 2025-11-03 11:01:52 +01:00
281 changed files with 15554 additions and 5838 deletions
+16 -43
View File
@@ -1,64 +1,37 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: build
stage: buildAndDeployApkUnstable
script:
- sh deploy-unstable.sh
only:
- tags
except:
- ^(dev)
when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable:
stage: build
stage: buildAndDeployApkStable
script:
- sh deploy-stable.sh
only:
- tags
except:
- branches
when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore:
stage: deploy
stage: buildAndDeployPlaystore
script:
- sh build-playstore.sh
- bash venv-playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
- sh deploy-playstore.sh
only:
- tags
when: on_success
needs:
- buildAndDeployApkStable
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/bundle/playstoreRelease/*.aab
updateFdroidRepo:
stage: deploy
only:
- tags
when: on_success
needs:
- job: buildAndDeployApkStable
artifacts: true
before_script:
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
script:
- python3 update_fdroid_index.py
except:
- branches
when: manual
-18
View File
@@ -106,21 +106,3 @@
[submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/radiobrowser"]
path = app/src/unstable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/radiobrowser"]
path = app/src/stable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/redbull-tv"]
path = app/src/stable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/redbull-tv"]
path = app/src/unstable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/fosdem"]
path = app/src/unstable/assets/sources/fosdem
url = ../plugins/fosdem.git
[submodule "app/src/stable/assets/sources/fosdem"]
path = app/src/stable/assets/sources/fosdem
url = ../plugins/fosdem.git
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
size 6342128
+13 -8
View File
@@ -184,13 +184,13 @@ dependencies {
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
implementation 'androidx.media3:media3-transformer:1.8.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1'
@@ -206,7 +206,6 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
@@ -231,4 +230,10 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
}
@@ -0,0 +1,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)
}
}
+1 -1
View File
@@ -60,7 +60,7 @@
<activity
android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
-7
View File
@@ -415,8 +415,6 @@ class VideoUrlSource {
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
@@ -514,8 +512,6 @@ class HLSSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashSource {
@@ -529,8 +525,6 @@ class DashSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashWidevineSource extends DashSource {
@@ -556,7 +550,6 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.original = obj?.original;
}
}
@@ -118,13 +118,14 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
inline fun V8Plugin.ensureIsBusy() {
this.let {
if (!it.isThreadAlreadyBusy()) {
//throw IllegalStateException("Tried to access V8Plugin without busy");
val stacktrace = Thread.currentThread().stackTrace;
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
Logger.w("Extensions_V8", message);
throw IllegalStateException(message);
Logger.w("Extensions_V8",
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
);
}
}
}
@@ -135,7 +136,8 @@ inline fun V8Value.ensureIsBusy() {
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
ensureIsBusy();
if(false)
ensureIsBusy();
return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> {
@@ -184,14 +186,10 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
}
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
obj.ensureIsBusy();
return obj.keys.map { obj.getString(it) };
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null)
return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop));
@@ -205,27 +203,21 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
promiseException = p0?.toException(plugin.config);
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
promiseException = p0?.toException(plugin.config);
latch.countDown();
}
});
@@ -237,23 +229,20 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
}
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
val isPending = plugin.busy {
promise.isPending
};
if(!isPending) {
plugin.busy {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
if(!promise.isPending) {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
} else {
catch(ex: Throwable) {
promiseException = ex;
}
}
else {
plugin.unbusy {
latch.await();
}
@@ -277,19 +266,15 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
override fun onRejected(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
@@ -297,11 +282,9 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
override fun onCatch(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
@@ -317,7 +300,6 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this;
if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
@@ -367,7 +349,6 @@ class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Defer
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -375,7 +356,6 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
return result as T;
}
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueAsync(this.getSourcePlugin()!!);
@@ -383,7 +363,6 @@ fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?):
return V8Deferred(CompletableDeferred(result as T));
}
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -391,7 +370,6 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
return result;
}
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
@@ -421,4 +399,4 @@ fun <T> IPager<T>.toList(): List<T> {
}
return list.toList();
}
}
@@ -6,7 +6,6 @@ import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
@@ -313,7 +312,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = true;
var useSubscriptionExchange: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -388,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context? = null): String? {
fun getPrimaryLanguage(context: Context): String? {
return when(primaryLanguage) {
0 -> "en";
1 -> "es";
@@ -401,11 +400,10 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tr";
11 -> "tu";
12 -> "ru";
13 -> "pt";
14 -> "zh";
15 -> "it";
else -> null
}
}
@@ -727,6 +725,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = true
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -789,6 +792,7 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@@ -797,9 +801,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
var clearCookiesAfterLogin: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -809,12 +810,6 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
fun shouldClearWebviewCookies(): Boolean {
return clearCookiesAfterLogin;
}
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -882,7 +877,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = true;
var shouldBackgroundDownload: Boolean = false;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
@@ -962,31 +957,18 @@ class Settings : FragmentedStorageFileJson() {
class Backup {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var didAskAutoBackup: Boolean = true;
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupEnabled
fun shouldAutomaticBackup() = autoBackupPassword != null;
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
StateApp.instance.activity?.let { activity ->
if(!Settings.instance.storage.isStorageMainValid(activity)) {
UIDialogs.toast("Missing general directory")
StateApp.instance.changeExternalGeneralDirectory(activity) {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
};
}
else {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
}
}
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
SettingsFragment.currentView?.reloadSettings();
};
}
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
@@ -1070,6 +1052,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
}
@@ -19,7 +19,6 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -166,42 +165,27 @@ class UIDialogs {
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog)
dialog.setOnDismissListener {
registerDialogClosed(dialog)
onClosed?.invoke()
}
dialog.show()
}
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
UIDialogs.showDialog(
context,
R.drawable.ic_move_up,
context.getString(R.string.an_old_backup_is_available),
context.getString(R.string.would_you_like_to_restore_this_backup),
null,
0,
UIDialogs.Action(context.getString(R.string.cancel), {}),
UIDialogs.Action(context.getString(R.string.continue_anyway), {
dialogAction()
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
?: StateApp.instance.scopeOrNull
?: StateApp.instance.scope
UIDialogs.showAutomaticRestoreDialog(context, scope)
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY)
)
} else {
dialogAction()
);
else {
dialogAction();
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog);
@@ -5,7 +5,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
@@ -32,7 +31,6 @@ 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.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@@ -76,8 +74,6 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays {
companion object {
@@ -383,8 +379,7 @@ class UISlideOverlays {
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -517,7 +512,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
slideUpMenuOverlay.hide()
}
@@ -528,11 +523,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
@@ -578,51 +573,6 @@ class UISlideOverlays {
return null;
}
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context,
@@ -659,13 +609,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
is JSDashManifestRawSource -> {
@@ -685,13 +629,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
is IHLSManifestSource -> {
@@ -705,13 +643,7 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
else -> {
@@ -5,14 +5,50 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
@@ -1,12 +1,11 @@
package com.futo.platformplayer
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -36,16 +35,18 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
return@withContext Result.success()
}
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
try {
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
}
Result.success()
@@ -1,22 +1,19 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import androidx.core.app.NotificationManagerCompat
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() {
@@ -28,6 +25,8 @@ class UpdateDownloadService : Service() {
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
@@ -52,7 +51,6 @@ class UpdateDownloadService : Service() {
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
StateUpdate.Companion.instance.clearUi()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
@@ -72,10 +70,6 @@ class UpdateDownloadService : Service() {
isDownloading = true
cancelRequested = false
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
@@ -91,17 +85,13 @@ class UpdateDownloadService : Service() {
job.cancel()
}
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
if(onProgress != null)
onProgress.invoke(progress);
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
}
}
@@ -109,7 +99,6 @@ class UpdateDownloadService : Service() {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
@@ -117,14 +106,6 @@ class UpdateDownloadService : Service() {
return
}
try {
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
ImageVariable.fromResource(R.drawable.foreground));
}
catch(ex: Exception){
Logger.e(TAG, "Failed to set progress announcement", ex);
}
var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) {
@@ -134,13 +115,7 @@ class UpdateDownloadService : Service() {
}
try {
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
}
catch(ex: Throwable) {}
})
performDownload(StateUpdate.APK_URL, partialFile, version)
if (!cancelRequested) {
if (apkFile.exists()) {
@@ -161,7 +136,6 @@ class UpdateDownloadService : Service() {
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
StateUpdate.Companion.instance.setUiFailed(version, t.message)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
@@ -171,12 +145,6 @@ class UpdateDownloadService : Service() {
}
}
} finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false
cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE)
@@ -184,7 +152,7 @@ class UpdateDownloadService : Service() {
}
}
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
private fun performDownload(url: String, partialFile: File, version: Int) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
@@ -236,7 +204,7 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100
else -> progress
}
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
}
} else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
@@ -267,16 +235,27 @@ class UpdateDownloadService : Service() {
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
StateUpdate.Companion.instance.setUiReady(version, apkFile)
try {
val ctx = applicationContext
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
}
} catch (ex: Throwable) {
Logger.w(TAG, "Failed to register install announcement", ex)
}
}
}
@@ -21,7 +21,6 @@ import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@@ -62,17 +61,6 @@ object UpdateInstaller {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val dataLength = apkFile.length()
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
if (usable in 0 until dataLength) {
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
Logger.w(TAG, msg)
withContext(Dispatchers.Main) {
UIDialogs.toast(context, msg)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
return@launch
}
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@@ -80,6 +68,7 @@ object UpdateInstaller {
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
@@ -102,18 +91,11 @@ object UpdateInstaller {
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
val raw = e.message ?: ""
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
"Not enough storage to install update. Free up some space and try again."
} else {
"Failed to install update: $raw"
}
withContext(Dispatchers.Main) {
UIDialogs.toast(context, friendly)
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally {
session?.close()
inputStream?.close()
@@ -128,12 +110,10 @@ object UpdateInstaller {
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
StateUpdate.instance.clearUi()
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
StateUpdate.instance.setUiReady(version, apkFile)
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
@@ -22,6 +22,9 @@ object UpdateNotificationManager {
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
@@ -29,6 +32,7 @@ object UpdateNotificationManager {
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
@@ -80,6 +84,43 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
@@ -182,9 +223,11 @@ object UpdateNotificationManager {
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -68,15 +68,11 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body");
else null;
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (captchaConfig.userAgent != null)
_webView.settings.userAgentString = captchaConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
@@ -61,15 +61,11 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -110,7 +110,6 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
@@ -202,7 +201,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
@@ -391,7 +389,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
@@ -541,7 +538,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -1372,7 +1368,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
@@ -1543,4 +1538,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return sourcesIntent;
}
}
}
}
@@ -23,7 +23,6 @@ import java.time.Duration
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis
open class ManagedHttpClient {
@@ -90,16 +89,10 @@ open class ManagedHttpClient {
return clonedClient;
}
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
if (modifier == null) return Pair(url, headers)
val modified = modifier.modifyRequest(url, headers)
return Pair(modified.url ?: url, modified.headers.toMutableMap())
}
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
fun tryHead(url: String): Map<String, String>? {
ensureNotMainThread()
try {
val result = head(url, HashMap(), modifier);
val result = head(url);
if(result.isOk)
return result.getHeadersFlat();
else
@@ -148,14 +141,12 @@ open class ManagedHttpClient {
return Socket(websocket);
}
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "GET", null, finalHeaders));
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "GET", null, headers));
}
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "HEAD", null, headers));
}
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
@@ -12,9 +12,6 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -12,9 +12,6 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -14,9 +14,6 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String {
return url
}
@@ -9,6 +9,4 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val language: String?;
val original: Boolean?;
}
@@ -16,10 +16,6 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String;
val fileSize : Long;
@@ -19,9 +19,6 @@ open class VideoUrlSource(
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String {
return url;
}
@@ -55,7 +55,6 @@ 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.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
@@ -157,7 +156,6 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -191,7 +189,6 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -488,14 +485,13 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) {
return _peekChannelTypes!!;
}
return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return@busy _peekChannelTypes ?: listOf();
}
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);
@@ -524,12 +520,10 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim");
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return@busy null;
return@busy value.value;
}
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return null;
return value.value;
}
@JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
@@ -539,30 +533,28 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map");
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return@busy mapOf();
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return mapOf();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
channelClaimTemplates = claimTypes.toMap();
return@busy claimTypes;
}
channelClaimTemplates = claimTypes.toMap();
return claimTypes;
}
@@ -703,33 +695,27 @@ open class JSClient : IPlatformClient {
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> {
ensureEnabled();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> {
ensureEnabled();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
fun validate() {
@@ -905,4 +891,4 @@ open class JSClient : IPlatformClient {
return name;
}
}
}
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
@@ -15,25 +15,23 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
}
private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
return Json.encodeToString(SerializedAuth(cookieMap, headers));
}
companion object {
val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
private fun deserialize(str: String): SourceAuth {
val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
@@ -15,25 +15,23 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
}
companion object {
val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -61,11 +61,6 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
fun isOfficialAuthor(): Boolean {
return scriptSignature != null &&
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
}
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
if(url == null)
return null;
@@ -170,12 +165,6 @@ class SourcePluginConfig(
"Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
))
/*if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
list.add(Pair(
"Browser Interop",
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
))
}*/
return list;
}
@@ -235,8 +224,7 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null,
val isAdvanced: Boolean? = null
val options: List<String>? = null
) {
val variableOrName: String get() = variable ?: name;
}
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
@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 = true;
var automaticUpdate: Boolean = false;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();
@@ -21,7 +21,6 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle(
@@ -35,7 +34,7 @@ open class JSArticle(
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
override val thumbnails: Thumbnails? =
if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
if (obj.has("thumbnails"))
Thumbnails.fromV8(
config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
@@ -31,20 +31,18 @@ open class JSArticleDetails(
final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
private val _hasGetComments: Boolean = _content.has("getComments")
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
override val rating: IRating = client.busy {
override val rating: IRating =
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
}
override val summary: String = client.busy {
override val summary: String =
_content.getOrThrow(client.config, "summary", "PlatformArticle")
}
override val thumbnails: Thumbnails? = client.busy {
override val thumbnails: Thumbnails? =
if (_content.has("thumbnails"))
Thumbnails.fromV8(
client.config,
@@ -52,19 +50,14 @@ open class JSArticleDetails(
)
else
null
}
override val segments: List<IJSArticleSegment> = client.busy {
override val segments: List<IJSArticleSegment> =
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) }
?: emptyList()
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(!_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -80,10 +73,7 @@ open class JSArticleDetails(
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -97,31 +87,25 @@ open class JSArticleDetails(
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
return client.busy {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
}
}
@@ -192,4 +176,4 @@ class JSNestedSegment: IJSArticleSegment {
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
nested = IJSContent.fromV8(client, nestedObj);
}
}
}
@@ -46,45 +46,23 @@ class JSComment : IPlatformComment {
_comment = obj;
_plugin = plugin;
var parsedContextUrl: String? = null;
var parsedAuthor: PlatformAuthorLink? = null;
var parsedMessage: String? = null;
var parsedRating: IRating? = null;
var parsedDate: OffsetDateTime? = null;
var parsedReplyCount: Int? = null;
var parsedContext: Map<String, String>? = null;
var parsedHasGetReplies = false;
plugin.busy {
val contextName = "Comment";
parsedContextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
parsedAuthor = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
parsedMessage = _comment!!.getOrThrow(config, "message", contextName);
parsedRating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
parsedDate = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) };
parsedReplyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
parsedContext = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
parsedHasGetReplies = _comment!!.has("getReplies");
}
contextUrl = parsedContextUrl ?: "";
author = parsedAuthor ?: PlatformAuthorLink.UNKNOWN;
message = parsedMessage ?: "";
rating = parsedRating ?: throw IllegalStateException("Missing comment rating");
date = parsedDate;
replyCount = parsedReplyCount;
context = parsedContext ?: hashMapOf();
_hasGetReplies = parsedHasGetReplies;
val contextName = "Comment";
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
message = _comment!!.getOrThrow(config, "message", contextName);
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) }
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
_hasGetReplies = _comment!!.has("getReplies");
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetReplies)
return null;
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return plugin.busy {
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
return@busy JSCommentPager(_config!!, plugin, obj);
}
return JSCommentPager(_config!!, plugin, obj);
}
}
}
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.decodeUnicode
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -24,8 +23,7 @@ open class JSContent(
override val contentType: ContentType = ContentType.UNKNOWN
protected val _hasGetDetails: Boolean =
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
protected val _hasGetDetails: Boolean = _content.has("getDetails")
override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
@@ -41,9 +41,7 @@ abstract class JSPager<T> : IPager<T> {
}
override fun hasMorePages(): Boolean {
return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
return _hasMorePages && !pager.isClosed;
}
override fun nextPage() {
@@ -93,4 +91,4 @@ abstract class JSPager<T> : IPager<T> {
}
abstract fun convertResult(obj: V8ValueObject): T;
}
}
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
@@ -31,79 +30,52 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
var parsedRating: IRating? = null;
var parsedTextType: TextType? = null;
var parsedContent: String? = null;
var parsedHasGetComments = false;
var parsedHasGetContentRecommendations = false;
val contextName = "PlatformPostDetails";
val parse = {
val contextName = "PlatformPostDetails";
rating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
parsedTextType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
parsedContent = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedHasGetComments = _content.has("getComments");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
};
obj.getSourcePlugin()?.busy {
parse();
} ?: parse()
rating = parsedRating ?: RatingLikes(0);
textType = parsedTextType ?: TextType.RAW;
content = parsedContent ?: "";
_hasGetComments = parsedHasGetComments;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetComments = jsClient.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(!_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client);
}
return getCommentsJS(jsClient);
else if(client is JSClient)
return getCommentsJS(client);
return null;
}
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetContentRecommendations = jsClient.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client);
}
return getContentRecommendationsJS(jsClient);
else if(client is JSClient)
return getContentRecommendationsJS(client);
return null;
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
}
}
@@ -41,26 +41,20 @@ class JSRequestExecutor: AutoCloseable {
this._config = plugin.config;
val config = plugin.config;
var parsedUrlPrefix: String? = null;
var parsedHasCleanup = false;
plugin.busy {
parsedUrlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
parsedHasCleanup = executor.has("cleanup");
}
urlPrefix = parsedUrlPrefix;
hasCleanup = parsedHasCleanup;
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup");
}
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
return _plugin.getUnderlyingPlugin().busy {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -114,12 +108,10 @@ class JSRequestExecutor: AutoCloseable {
open fun cleanup() {
_plugin.busy {
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return@busy;
_cleaned = true;
}
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return;
_cleaned = true;
}
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
_plugin.busy {
@@ -171,4 +163,4 @@ class ExecutorParameters {
var rangeEnd: Int = -1;
var segment: Int = -1;
}
}
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
}
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
return _plugin.busy {
if (_modifier.isClosed) {
return@busy Request(url, headers);
}
if (_modifier.isClosed) {
return Request(url, headers);
}
return _plugin.busy {
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invokeV8("modifyRequest", url, headers);
} as V8ValueObject;
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
}
}
@@ -29,28 +29,12 @@ class JSSubtitleSource : ISubtitleSource {
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
_obj = v8Value;
var parsedName: String? = null;
var parsedLanguage: String? = null;
var parsedUrl: String? = null;
var parsedFormat: String? = null;
var parsedHasFetch = false;
val parse = {
val context = "JSSubtitles";
parsedName = v8Value.getOrThrow(config, "name", context, false);
parsedLanguage = v8Value.getOrDefault(config, "language", context, null);
parsedUrl = v8Value.getOrThrow(config, "url", context, true);
parsedFormat = v8Value.getOrThrow(config, "format", context, true);
parsedHasFetch = v8Value.has("getSubtitles");
};
v8Value.getSourcePlugin()?.busy {
parse();
} ?: parse()
name = parsedName ?: "";
language = parsedLanguage;
url = parsedUrl;
format = parsedFormat;
hasFetch = parsedHasFetch;
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrDefault(config, "language", context, null);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
}
override fun getSubtitles(): String {
@@ -85,4 +69,4 @@ class JSSubtitleSource : ISubtitleSource {
return JSSubtitleSource(config, value);
}
}
}
}
@@ -52,63 +52,34 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin;
var parsedDescription: String? = null;
var parsedVideo: IVideoSourceDescriptor? = null;
var parsedDash: IDashManifestSource? = null;
var parsedHls: IHLSManifestSource? = null;
var parsedLive: IVideoSource? = null;
var parsedRating: IRating? = null;
var parsedSubtitles: List<ISubtitleSource>? = null;
var parsedHasGetComments = false;
var parsedHasGetPlaybackTracker = false;
var parsedHasGetContentRecommendations = false;
var parsedHasGetVODEvents = false;
val config = plugin.config;
description = _content.getOrThrow(config, "description", 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));
plugin.busy {
val contextName = "VideoDetails";
val config = plugin.config;
parsedDescription = _content.getOrThrow(config, "description", contextName);
parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
parsedLive = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
parsedRating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
if(!_content.has("subtitles"))
parsedSubtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
parsedSubtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
parsedSubtitles = listOf();
}
parsedHasGetComments = _content.has("getComments");
parsedHasGetPlaybackTracker = _content.has("getPlaybackTracker");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
parsedHasGetVODEvents = _content.has("getVODEvents");
if(!_content.has("subtitles"))
subtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
subtitles = listOf();
}
description = parsedDescription ?: "";
video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
dash = parsedDash;
hls = parsedHls;
live = parsedLive;
rating = parsedRating ?: RatingLikes(0);
subtitles = parsedSubtitles ?: listOf();
_hasGetComments = parsedHasGetComments;
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetVODEvents = parsedHasGetVODEvents;
_hasGetComments = _content.has("getComments");
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
_hasGetVODEvents = _content.has("getVODEvents");
}
override fun getPlaybackTracker(): IPlaybackTracker? {
val canGetPlaybackTracker = _plugin.busy {
_hasGetPlaybackTracker && !_content.isClosed
}
if(!canGetPlaybackTracker)
if(!_hasGetPlaybackTracker || _content.isClosed)
return null;
if(_pluginConfig.id == StateDeveloper.DEV_ID)
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
@@ -131,10 +102,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val canGetContentRecommendations = _plugin.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -154,12 +122,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(client !is JSClient)
return null;
val canGetComments = _plugin.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(client !is JSClient || !_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -190,4 +153,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return@busy JSVODEventPager(_plugin.config, _plugin,
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
}
}
}
@@ -39,10 +39,10 @@ open class JSAudioUrlSource(
?: "$container $bitrate"
override var priority: Boolean =
plugin.busy { if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false }
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
override var original: Boolean =
plugin.busy { if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false }
if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
override fun getAudioUrl(): String = url
@@ -19,23 +19,21 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
@@ -54,8 +54,8 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = plugin.busy { _obj.has("generate") };
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
hasGenerate = _obj.has("generate");
}
private var _pregenerate: V8Deferred<String?>? = null;
@@ -67,7 +67,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
@@ -111,7 +111,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
@@ -145,4 +145,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
}
return result;
}
}
}
@@ -39,10 +39,6 @@ open class JSDashManifestRawSource(
private val ctx = "DashRawSource"
private val cfg = plugin.config
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
@@ -73,13 +69,12 @@ open class JSDashManifestRawSource(
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = plugin.busy { _obj.has("generate") }
override val hasGenerate: Boolean = _obj.has("generate")
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
var audioStreamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
@@ -90,7 +85,7 @@ open class JSDashManifestRawSource(
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
@@ -126,14 +121,6 @@ open class JSDashManifestRawSource(
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
return@busy result.convert {
it.value
};
@@ -142,7 +129,7 @@ open class JSDashManifestRawSource(
override open fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
var result: String? = null;
@@ -171,14 +158,6 @@ open class JSDashManifestRawSource(
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
}
}
return result;
@@ -206,9 +185,6 @@ class JSDashManifestMergingRawSource(
override val priority: Boolean
get() = video.priority;
override val language: String? get() = audio.language
override val original: Boolean? get() = audio.original;
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
val videoDashDef = video.generateAsync(scope);
val audioDashDef = audio.generateAsync(scope);
@@ -258,4 +234,4 @@ class JSDashManifestMergingRawSource(
companion object {
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
}
}
}
@@ -21,9 +21,6 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashSource";
val config = plugin.config;
@@ -32,9 +29,6 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
duration = _obj.getOrThrow(config, "duration", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = obj.getOrNull(config, "language", contextName);
original = obj.getOrNull(config, "original", contextName);
}
override fun getVideoUrl(): String {
@@ -28,9 +28,6 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
override val language: String?;
override val original: Boolean?;
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
@@ -42,29 +39,24 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}
}
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = obj.getOrNull(config, "original", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
@@ -21,9 +21,6 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSSource";
val config = plugin.config;
@@ -33,8 +30,5 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
}
@@ -44,26 +44,15 @@ abstract class JSSource {
this._obj = obj;
this.type = type;
var parsedRequestModifier: JSRequest? = null;
var parsedHasRequestModifier = false;
var parsedRequestExecutor: JSRequest? = null;
var parsedHasRequestExecutor = false;
plugin.busy {
parsedRequestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestModifier = parsedRequestModifier != null || obj.has("getRequestModifier");
parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestExecutor = parsedRequestExecutor != null || obj.has("getRequestExecutor");
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
_requestModifier = parsedRequestModifier;
hasRequestModifier = parsedHasRequestModifier;
_requestExecutor = parsedRequestExecutor;
hasRequestExecutor = parsedHasRequestExecutor;
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
}
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
@@ -177,4 +166,4 @@ abstract class JSSource {
}
}
}
}
}
@@ -44,9 +44,6 @@ open class JSVideoUrlSource(
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override fun getVideoUrl(): String = url
override fun toString(): String =
@@ -18,27 +18,25 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}
}
@@ -20,9 +20,6 @@ class LocalVideoContentSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
@@ -20,9 +20,6 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File;
constructor(file: File) {
@@ -0,0 +1,330 @@
package com.futo.platformplayer.casting
import android.os.Looper
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.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
class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
if (resumePosition > 0.0) {
val pos = resumePosition / duration;
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
} 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?) {
throw NotImplementedError();
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
post("scrub?position=${timeSeconds}");
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
isPlaying = true;
post("rate?value=1.000000");
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
isPlaying = false;
post("rate?value=0.000000");
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
post("stop");
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
post("stop");
stop();
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
_started = true;
_scopeIO?.cancel();
_scopeIO = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting...");
_scopeIO?.launch {
try {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
_sessionId = UUID.randomUUID().toString();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
while (_scopeIO?.isActive == true) {
try {
val progressInfo = getProgress();
if (progressInfo == null) {
connectionState = CastConnectionState.CONNECTING;
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
delay(1000);
continue;
}
connectionState = CastConnectionState.CONNECTED;
val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) {
delay(1000);
continue;
}
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(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)
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
}
};
Logger.i(TAG, "Started.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
connectionState = CastConnectionState.DISCONNECTED;
usedRemoteAddress = null;
localAddress = null;
_started = false;
_scopeIO?.cancel();
_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);
}
private fun getProgress(): String? {
val info = get("scrub");
Logger.i(TAG, "Progress: ${info ?: "null"}");
return info;
}
private fun getPlaybackInfo(): String? {
val playbackInfo = get("playback-info");
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
return playbackInfo;
}
private fun getServerInfo(): String? {
val serverInfo = get("server-info");
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
return serverInfo;
}
private fun post(path: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"Content-Length" to "0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url");
val response = _client.post(url, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path");
return false;
}
}
private fun post(path: String, contentType: String, body: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId,
"Content-Type" to contentType
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url:\n$body");
val response = _client.post(url, body, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path $body");
return false;
}
}
private fun get(path: String): String? {
val sessionId = _sessionId ?: return null;
try {
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"Content-Length" to "0",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "GET $url");
val response = _client.get(url, headers);
if (!response.isOk) {
return null;
}
if (response.body == null) {
return null;
}
return response.body.string();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to GET $path");
return null;
}
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
companion object {
val TAG = "AirPlayCastingDevice";
}
}
@@ -1,217 +1,60 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import java.net.InetAddress
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
abstract class CastingDevice {
abstract val isReady: Boolean
abstract val usedRemoteAddress: InetAddress?
abstract val localAddress: InetAddress?
abstract val name: String?
abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
@Throws
abstract fun resumePlayback()
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
@Throws
abstract fun pausePlayback()
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
@Throws
abstract fun stopPlayback()
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
@Throws
abstract fun seekTo(timeSeconds: Double)
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
@Throws
abstract fun changeVolume(timeSeconds: Double)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
@Throws
abstract fun changeSpeed(speed: Double)
class CastingDevice(val device: RsCastingDevice) {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
@Throws
abstract fun connect()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
val isReady: Boolean
get() = device.isReady()
val name: String
get() = device.name()
var usedRemoteAddress: InetAddress? = null
var localAddress: InetAddress? = null
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
val onConnectionStateChanged =
Event1<CastConnectionState>()
val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
fun resumePlayback() = device.resumePlayback()
fun pausePlayback() = device.pausePlayback()
fun stopPlayback() = device.stopPlayback()
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
fun disconnect() = device.disconnect()
fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
fun loadVideo(
@Throws
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
@@ -219,107 +62,18 @@ class CastingDevice(val device: RsCastingDevice) {
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
fun loadContent(
@Throws
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
var connectionState = CastConnectionState.DISCONNECTED
val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
var volume: Double = 1.0
var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
var speed: Double = 0.0
var isPlaying: Boolean = false
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
abstract fun ensureThreadStarted()
}
@@ -0,0 +1,271 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -0,0 +1,242 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -0,0 +1,736 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
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
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _streamType: String? = null;
private var _contentType: String? = null;
private var _contentId: String? = null;
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;
private var _sessionId: String? = null;
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
_streamType = streamType;
_contentType = contentType;
_contentId = contentId;
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
private fun connectMediaChannel(transportId: String) {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
}
private fun requestMediaStatus() {
val transportId = _transportId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "GET_STATUS");
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun playVideo() {
val transportId = _transportId ?: return;
val contentId = _contentId ?: return;
val streamType = _streamType ?: return;
val contentType = _contentType ?: return;
val loadObject = JSONObject();
loadObject.put("type", "LOAD");
val mediaObject = JSONObject();
mediaObject.put("contentId", contentId);
mediaObject.put("streamType", streamType);
mediaObject.put("contentType", contentType);
if (time > 0.0) {
val seekTime = time;
loadObject.put("currentTime", seekTime);
}
loadObject.put("media", mediaObject);
loadObject.put("requestId", _requestId++);
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
val json = loadObject.toString().replace("\\/","/");
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume)
val setVolumeObject = JSONObject();
setVolumeObject.put("type", "SET_VOLUME");
val volumeObject = JSONObject();
volumeObject.put("level", volume)
setVolumeObject.put("volume", volumeObject);
setVolumeObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "SEEK");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
loadObject.put("currentTime", timeSeconds);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PLAY");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PAUSE");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
_contentId = null;
_contentType = null;
_streamType = null;
val loadObject = JSONObject();
loadObject.put("type", "STOP");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun launchPlayer() {
if (invokeInIOScopeIfRequired(::launchPlayer)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "LAUNCH");
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
if (invokeInIOScopeIfRequired(::getStatus)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "GET_STATUS");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
val sessionId = _sessionId;
if (sessionId != null) {
val launchObject = JSONObject();
launchObject.put("type", "STOP");
launchObject.put("sessionId", sessionId);
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_contentId = null;
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
_launching = true;
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
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")
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
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;
val message = 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 null
}
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 msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} 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;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
//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) {
try {
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
sendMessage(castMessage.toByteArray());
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
//Log.d(TAG, "Sent channel message: $castMessage");
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
val status = jsonObject.getJSONObject("status");
var sessionIsRunning = false;
if (status.has("applications")) {
val applications = status.getJSONArray("applications");
for (i in 0 until applications.length()) {
val applicationUpdate = applications.getJSONObject(i);
val appId = applicationUpdate.getString("appId");
Logger.i(TAG, "Status update received appId (appId: $appId)");
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
Logger.i(TAG, "Connected to media channel $transportId");
_transportId = transportId;
requestMediaStatus();
}
}
}
}
if (!sessionIsRunning) {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
_transportId = null
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
//val volumeControlType = volume.getString("controlType");
val volumeLevel = volume.getString("level").toDouble();
val volumeMuted = volume.getBoolean("muted");
//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") {
val statuses = jsonObject.getJSONArray("status");
for (i in 0 until statuses.length()) {
val status = statuses.getJSONObject(i);
_mediaSessionId = status.getInt("mediaSessionId");
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 || playerState == "PAUSED") {
setTime(currentTime);
}
val playbackRate = status.getInt("playbackRate");
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
if (_contentType == null) {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
throw Exception("Payload type ${message.payloadType} is not implemented.");
}
}
private fun sendMessage(data: ByteArray) {
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
return;
}
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.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
_mediaSessionId = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "ChromecastCastingDevice";
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
});
}
}
@@ -0,0 +1,636 @@
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.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.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.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),
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 : CastingDeviceLegacy {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _socket: Socket? = 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
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
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?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
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) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume);
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return;
}
setSpeed(speed);
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
send(Opcode.Resume);
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
send(Opcode.Pause);
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
stopVideo();
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_started = true;
Logger.i(TAG, "Starting...");
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)
Thread.sleep(1000);
}
}
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
_lastPongTime = System.currentTimeMillis()
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...");
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].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().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) {
if (connectionState == CastConnectionState.CONNECTED) {
try {
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} 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)
}
}
}
Thread.sleep(5000)
}
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.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
}
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
setTime(playbackUpdate.time, playbackUpdate.generationTime);
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
}
}
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
}
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
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
}
}
}
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
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))
}
}
}
@@ -8,7 +8,6 @@ import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -41,7 +40,6 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -58,22 +56,16 @@ import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.DeviceInfo
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.NsdDeviceDiscoverer
import org.fcast.sender_sdk.ProtocolType
import java.net.Inet6Address
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
class StateCasting {
abstract class StateCasting {
val _scopeIO = CoroutineScope(Dispatchers.IO);
val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
@@ -90,7 +82,6 @@ class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null
@@ -99,163 +90,15 @@ class StateCasting {
val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0)
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
abstract fun handleUrl(url: String)
abstract fun onStop()
abstract fun start(context: Context)
abstract fun stop()
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDevice(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDevice(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDevice) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
abstract fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
): Job?
fun onResume() {
val ad = activeDevice
@@ -302,7 +145,6 @@ class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect()
}
@@ -317,7 +159,6 @@ class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null;
}
@@ -381,9 +222,6 @@ class StateCasting {
device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try {
device.connect();
@@ -394,7 +232,6 @@ class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return;
}
@@ -920,7 +757,6 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -928,7 +764,6 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -970,7 +805,8 @@ class StateCasting {
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
@@ -1023,7 +859,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
val response = _client.get(variantPlaylistRef.url)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1060,7 +896,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
val response = _client.get(mediaRendition.uri)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1191,7 +1027,6 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1269,7 +1104,6 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1353,7 +1187,6 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -1361,7 +1194,6 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -1401,47 +1233,6 @@ class StateCasting {
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
private fun escapeXml(s: String): String =
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun injectSubtitleAdaptationSet(
mpd: String,
subtitleUrl: String,
mimeType: String,
lang: String = "und",
label: String = "Subtitles"
): String {
val mt = mimeType.lowercase()
val codecs = when (mt) {
"text/vtt", "text/webvtt" -> "wvtt"
"application/ttml+xml", "application/ttml" -> "stpp"
else -> null
}
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
val adaptation = """
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Label>${escapeXml(label)}</Label>
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
</Representation>
</AdaptationSet>
""".trimIndent()
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
return if (periodClose.containsMatchIn(mpd)) {
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
} else {
mpd
}
}
@OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -1463,42 +1254,30 @@ class StateCasting {
val videoUrl = url + videoPath
val audioUrl = url + audioPath
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
subtitleSource.getSubtitlesURI()
} else null
return@withContext subtitleSource.getSubtitlesURI();
} else null;
var subtitlesUrl: String? = null
var subtitlesUrl: String? = null;
if (subtitlesUri != null) {
when (subtitlesUri.scheme) {
"file", "content" -> {
val content = withContext(Dispatchers.IO) {
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
stream.bufferedReader().use { it.readText() }
}
}
if (!content.isNullOrEmpty()) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castDashRaw")
subtitlesUrl = url + subtitlePath
}
if(subtitlesUri.scheme == "file") {
var content: String? = null;
val inputStream = contentResolver.openInputStream(subtitlesUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
content = reader.use { it.readText() };
}
"http", "https" -> {
// Receiver will fetch directly (works only if it doesnt need auth/headers)
subtitlesUrl = subtitlesUri.toString()
if (content != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
else -> {
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
}
subtitlesUrl = url + subtitlePath;
} else {
subtitlesUrl = subtitlesUri.toString();
}
}
@@ -1544,22 +1323,8 @@ class StateCasting {
return emptyList()
}
if (subtitlesUrl != null) {
dashContent = injectSubtitleAdaptationSet(
dashContent,
subtitlesUrl!!,
subtitleMimeTypeForMpd
)
}
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value
@@ -1583,20 +1348,16 @@ class StateCasting {
throw Exception("Audio source without request executor not supported")
}
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldVideoExecutor = _videoExecutor
oldVideoExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor()
if (audioSource != null && audioSource.hasRequestExecutor) {
val oldExecutor = _audioExecutor;
oldExecutor?.closeAsync();
_audioExecutor = audioSource.getRequestExecutor()
}
if (audioSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldExecutor = _videoExecutor;
oldExecutor?.closeAsync();
_videoExecutor = videoSource.getRequestExecutor()
}
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1627,7 +1388,7 @@ class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw");
}
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -1692,7 +1453,11 @@ class StateCasting {
}
companion object {
var instance = StateCasting()
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
StateCastingExp()
} else {
StateCastingLegacy()
}
private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL
@@ -0,0 +1,178 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class StateCastingExp : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDeviceExp(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDeviceExp(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDeviceExp) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -0,0 +1,399 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class StateCastingLegacy : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
if (foundInfo != null) {
connectDevice(deviceFromInfo(foundInfo))
}
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return CastingDeviceLegacyWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "StateCastingLegacy"
}
}
@@ -0,0 +1,72 @@
package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Double? = null,
val speed: Double? = null
) { }
@Serializable
data class FCastSeekMessage(
val time: Double
) { }
@Serializable
data class FCastPlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@Serializable
data class FCastKeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class FCastDecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class FCastEncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
)
@@ -71,14 +71,16 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean {
var handled = false;
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke();
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke();
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke();
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke();
}
return handled;
}
@@ -86,15 +88,16 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value);
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value);
}
return handled;
}
@@ -103,14 +106,16 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean {
var handled = false;
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2);
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2);
}
return handled;
}
@@ -120,14 +125,16 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled = false;
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2, value3);
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2, value3);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2, value3);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2, value3);
}
return handled;
}
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
var inputStream: InputStream? = null;
try {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
@@ -1,13 +1,9 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
@@ -15,88 +11,88 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonStop: LinearLayout;
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start)
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
dismiss()
}
clearFocus();
dismiss();
};
_buttonStop.setOnClickListener {
dismiss()
Settings.instance.backup.autoBackupEnabled = false
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
UIDialogs.toast(context, "AutoBackup disabled");
}
_buttonStart.setOnClickListener {
dismiss()
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
val activity = StateApp.instance.activity as? Activity
if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
}
dismiss()
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
dismiss();
Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
UIDialogs.toast(context, "AutoBackup enabled")
UIDialogs.toast(context, "AutoBackup enabled");
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
StateBackup.startAutomaticBackup(true);
}
Settings.instance.backup.autoBackupEnabled = true
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
StateAnnouncement.instance.deleteAnnouncement("backup")
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
catch(ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex);
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
}
}
};
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
}
companion object {
private const val TAG = "AutomaticBackupDialog"
private val TAG = "AutomaticBackupDialog";
}
}
@@ -3,155 +3,87 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonCancel: MaterialButton
private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _progress: ProgressBar
private lateinit var _textStart: TextView
private lateinit var _imm: InputMethodManager
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editPassword: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private var _needsPassword: Boolean = true
private var _detectJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password)
_textReason = findViewById(R.id.text_reason)
_passwordContainer = findViewById(R.id.password_container)
_icon = findViewById(R.id.image_icon)
_progress = findViewById(R.id.progress_restore)
_textStart = findViewById(R.id.text_start)
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_needsPassword = true
applyMode(needsPassword = true)
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
clearFocus()
dismiss()
}
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
clearFocus();
dismiss();
};
override fun onStart() {
super.onStart()
_detectJob?.cancel()
_detectJob = scope.launch(Dispatchers.Main) {
val needs = try {
StateBackup.requiresPasswordForAutomaticBackup(context)
} catch (_: Throwable) {
true
_buttonStart.setOnClickListener {
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
if (!isShowing) return@launch
_needsPassword = needs
applyMode(needsPassword = needs)
setBusy(false)
}
}
override fun onStop() {
_detectJob?.cancel()
_detectJob = null
super.onStop()
}
private fun applyMode(needsPassword: Boolean) {
_textStart.setText(R.string.restore)
if (needsPassword) {
_icon.setImageResource(R.drawable.ic_lock)
_passwordContainer.visibility = View.VISIBLE
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
} else {
_icon.setImageResource(R.drawable.ic_move_up)
_passwordContainer.visibility = View.GONE
_editPassword.setText("")
_textReason.setText(R.string.automatic_backup_found_no_password)
}
}
private fun onStartClicked() {
val password = _editPassword.text?.toString() ?: ""
if (_needsPassword) {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32) {
_editPassword.error = context.getString(R.string.backup_password_length_error)
_editPassword.requestFocus()
return
}
}
clearFocus()
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
scope.launch(Dispatchers.IO) {
try {
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
withContext(Dispatchers.Main) {
if (isShowing) dismiss()
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex)
withContext(Dispatchers.Main) {
if (!isShowing) return@withContext
setBusy(false)
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
}
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
dismiss();
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex);
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
}
};
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
_progress.visibility = if (busy) View.VISIBLE else View.GONE
_buttonCancel.isEnabled = !lockCancel
_buttonStart.isEnabled = !busy
_editPassword.isEnabled = !busy && _needsPassword
_buttonStart.alpha = if (busy) 0.6f else 1.0f
_textStart.setText(labelRes)
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus()
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
}
companion object {
private const val TAG = "AutomaticRestoreDialog"
private val TAG = "AutomaticRestoreDialog";
}
}
}
@@ -40,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@@ -12,6 +12,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
@@ -173,7 +174,13 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(R.drawable.ic_fc)
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
}
@@ -9,9 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
@@ -42,13 +40,10 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads
@@ -91,9 +86,6 @@ import kotlin.time.times
class VideoDownload {
var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -109,7 +101,6 @@ class VideoDownload {
var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual
@Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -139,35 +130,19 @@ class VideoDownload {
@Contextual
@kotlinx.serialization.Transient
var videoSourceLive: JSSource? = null;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingPlugin()?.busy {
videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
var requiresLiveAudioSource: Boolean = false;
@Contextual
@kotlinx.serialization.Transient
var audioSourceLive: JSSource? = null;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingPlugin()?.busy {
audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
// serialized. After deserialization these are null - DownloadService must re-prepare to
// recapture them from the live plugin source (see needsReprepareForAuth).
@kotlinx.serialization.Transient
private var preparedVideoRequestModifier: IRequestModifier? = null;
@kotlinx.serialization.Transient
private var preparedAudioRequestModifier: IRequestModifier? = null;
val needsReprepareForAuth: Boolean get() =
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
var progress: Double = 0.0;
var isCancelled = false;
@@ -223,7 +198,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -232,22 +207,12 @@ class VideoDownload {
this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
this.subtitleSource = subtitleSource;
this.prepareTime = OffsetDateTime.now();
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
// Set modifier flags from either the source or an explicitly provided modifier
// (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
// These flags are serialized and used by needsReprepareForAuth after restore.
this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
// or DASH generation). Modifiers alone don't require a live source - they're already
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -305,7 +270,7 @@ class VideoDownload {
//Fetch full video object and determine source
if(video != null && videoDetails == null) {
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
@@ -343,10 +308,8 @@ class VideoDownload {
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -373,8 +336,6 @@ class VideoDownload {
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
preparedVideoRequestModifier = vsource.getRequestModifier()
}
if(vsource == null) {
@@ -396,10 +357,8 @@ class VideoDownload {
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -434,8 +393,6 @@ class VideoDownload {
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
preparedAudioRequestModifier = asource.getRequestModifier()
}
if(asource == null) {
@@ -480,11 +437,6 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
}
if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -532,23 +484,13 @@ class VideoDownload {
}
}
val videoModifier = preparedVideoRequestModifier
if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
videoOverrideContainer = "video/mp4";
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
}
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
});
@@ -582,19 +524,13 @@ class VideoDownload {
}
}
val audioModifier = preparedAudioRequestModifier
if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
audioOverrideContainer = "audio/mp4";
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
}
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
});
@@ -653,63 +589,51 @@ class VideoDownload {
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
suspendCancellableCoroutine { continuation ->
val concatInput = buildString {
append("concat:")
append(
segmentFiles.joinToString("|") { file ->
file.absolutePath
}
)
}
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//No callback
//TODO: Show progress?
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(
cmd,
{ completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ log ->
Logger.v(TAG, log.message)
},
{ Logger.v(TAG, it.message) },
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdownNow()
}
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
@@ -723,13 +647,17 @@ class VideoDownload {
}
}
val resp = client.get(url, headers, modifier)
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val bytes = body.bytes()
body.close()
return bytes
@@ -744,7 +672,12 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>()
try {
val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
@@ -923,19 +856,14 @@ class VideoDownload {
return downloadedTotalLength
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null;
try{
@@ -946,27 +874,14 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
val foundTemplateUrl = foundTemplate.groupValues[1];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor();
else
@@ -981,68 +896,39 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val data = executeOrGet(client, executor, modifier, url)
val modified = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written += data.size;
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val data = executeOrGet(client, executor, modifier, url2)
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
}
sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished");
}
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
@@ -1051,38 +937,15 @@ class VideoDownload {
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex;
}
finally {
fileStream.close();
fileStream2?.close();
executor?.closeAsync()
}
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!;
}
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -1091,8 +954,13 @@ class VideoDownload {
val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile);
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try {
val head = client.tryHead(videoUrl, modifier);
val head = client.tryHead(videoUrl);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
{
@@ -1167,7 +1035,12 @@ class VideoDownload {
var lastSpeed: Long = 0;
val result = client.get(url, HashMap(), modifier)
val result = if (modifier != null) {
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
if (!result.isOk) {
result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -1380,12 +1253,13 @@ class VideoDownload {
var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) {
try {
val toRead = rangeEnd - rangeStart;
val req = client.get(url, headers.toMutableMap(), modifier);
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
if (!req.isOk) {
val bodyString = req.body?.string()
req.body?.close()
@@ -1430,7 +1304,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioSourceToUse != null) {
if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!);
@@ -1453,7 +1327,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1462,9 +1336,6 @@ class VideoDownload {
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
if(localAudioSource != null && localAudioSource.streamMetaData == null && videoSourceToUse is JSDashManifestRawSource)
localAudioSource.streamMetaData = (videoSourceToUse as JSDashManifestRawSource).audioStreamMetaData;
if(existing != null) {
existing.videoSerialized = videoDetails!!;
if(localVideoSource != null) {
@@ -1498,10 +1369,6 @@ class VideoDownload {
}
}
fun cleanup(){
cleanupPluginClient()
}
enum class State {
QUEUED,
PREPARING,
@@ -1519,26 +1386,12 @@ class VideoDownload {
}
}
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
if (executor != null) {
val modified = modifier?.modifyRequest(url, headers)
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
} else {
val resp = client.get(url, headers.toMutableMap(), modifier)
if (!resp.isOk)
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
return resp.body!!.bytes()
}
}
companion object {
const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist";
const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? {
@@ -1558,16 +1411,6 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container)
}
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4"))
return "mp4a";
@@ -1622,4 +1465,4 @@ class VideoDownload {
}
}
}
}
@@ -13,10 +13,8 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
@@ -53,12 +51,8 @@ class VideoExport {
val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val safeBaseName = videoLocal.name.sanitizeFileName(true).ifBlank {
"video_${UUID.randomUUID()}"
}
if (sourceCount > 1) {
val outputFileName = "$safeBaseName.mp4"
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
@@ -66,9 +60,7 @@ class VideoExport {
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
@@ -76,29 +68,25 @@ class VideoExport {
}
outputFile = f;
} else if (v != null) {
val outputFileName = "$safeBaseName.${VideoDownload.videoContainerToExtension(v.container)}"
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else if (a != null) {
val outputFileName = "$safeBaseName.${VideoDownload.audioContainerToExtension(a.container)}"
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
@@ -110,48 +98,29 @@ class VideoExport {
return@coroutineScope outputFile;
}
private fun ffmpegArg(value: String): String {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"") + "\""
}
private fun resumeSuccessSafely(continuation: CancellableContinuation<Unit>) {
if (!continuation.isActive) return
try {
continuation.resumeWith(Result.success(Unit))
} catch (_: IllegalStateException) {
}
}
private fun resumeFailureSafely(continuation: CancellableContinuation<Unit>, throwable: Throwable) {
if (!continuation.isActive) return
try {
continuation.resumeWithException(throwable)
} catch (_: IllegalStateException) {
}
}
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
val cmdBuilder = StringBuilder("-y")
var counter = 0
if (inputPathVideo != null) {
cmdBuilder.append(" -i ${ffmpegArg(inputPathVideo)}")
cmdBuilder.append(" -i $inputPathVideo")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -i ${ffmpegArg(inputPathAudio)}")
cmdBuilder.append(" -i $inputPathAudio")
}
if (inputPathSubtitles != null) {
val subtitleExtension = File(inputPathSubtitles).extension.lowercase()
when (subtitleExtension) {
"srt", "vtt" -> {}
val subtitleExtension = File(inputPathSubtitles).extension
val codec = when (subtitleExtension.lowercase()) {
"srt" -> "mov_text"
"vtt" -> "webvtt"
else -> throw Exception("Unsupported subtitle format: $subtitleExtension")
}
cmdBuilder.append(" -i ${ffmpegArg(inputPathSubtitles)}")
cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles")
}
if (inputPathVideo != null) {
@@ -163,7 +132,6 @@ class VideoExport {
if (inputPathSubtitles != null) {
cmdBuilder.append(" -map ${counter++}")
cmdBuilder.append(" -c:s mov_text")
}
if (inputPathVideo != null) {
@@ -172,44 +140,33 @@ class VideoExport {
if (inputPathAudio != null) {
cmdBuilder.append(" -c:a copy")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -c:s mov_text")
}
cmdBuilder.append(" ${ffmpegArg(outputPath)}")
cmdBuilder.append(" $outputPath")
val cmd = cmdBuilder.toString()
Logger.i(TAG, "Used command: $cmd");
val statisticsCallback = StatisticsCallback { statistics ->
val time = statistics.time.toDouble() / 1000.0
val progressPercentage = if (duration > 0.0) {
(time / duration).coerceIn(0.0, 1.0)
} else {
0.0
}
onProgress?.let { callback ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
callback(progressPercentage)
}
}
val progressPercentage = (time / duration)
onProgress?.invoke(progressPercentage)
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
try {
if (ReturnCode.isSuccess(session.returnCode)) {
resumeSuccessSafely(continuation)
if (ReturnCode.isSuccess(session.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
resumeFailureSafely(continuation, RuntimeException(errorMessage))
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
} finally {
executorService.shutdown()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
LogCallback { Logger.v(TAG, it.message) },
@@ -219,7 +176,6 @@ class VideoExport {
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdown()
}
}
}
@@ -240,24 +196,14 @@ class VideoExport {
val totalBytes = srcFile.length()
var bytesCopied: Long = 0
if (totalBytes == 0L) {
onProgress?.let {
withContext(Dispatchers.Main) {
it(1.0)
}
}
return@withContext
}
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead.toLong()
onProgress?.let {
val progress = (bytesCopied / totalBytes.toDouble()).coerceIn(0.0, 1.0)
withContext(Dispatchers.Main) {
it(progress)
it(bytesCopied / totalBytes.toDouble())
}
}
}
@@ -15,9 +15,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.NoInternetException
@@ -36,7 +34,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageBrowser
import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageHttpImp
@@ -47,7 +44,6 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.toList
import com.futo.platformplayer.toV8ValueBlocking
import com.futo.platformplayer.toV8ValueAsync
@@ -58,7 +54,6 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -88,7 +83,6 @@ class V8Plugin {
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null;
val bridge: PackageBridge;
var isStopped = true;
val onStopped = Event1<V8Plugin>();
@@ -96,9 +90,6 @@ class V8Plugin {
private val _busyLock = ReentrantLock()
val isBusy get() = _busyLock.isLocked;
@Volatile
private var _busyHolder: Thread? = null;
var allowDevSubmit: Boolean = false
private set(value) {
field = value;
@@ -119,8 +110,7 @@ class V8Plugin {
this._clientAuth = clientAuth;
this.config = config;
this._script = script;
bridge = PackageBridge(this, config);
withDependency(bridge);
withDependency(PackageBridge(this, config));
for(pack in config.packages)
withDependency(getPackage(pack)!!);
@@ -165,53 +155,51 @@ class V8Plugin {
fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
tryBusy(BUSY_STARTUP_MS) {
synchronized(_runtimeLock) {
if (_runtime != null)
return@tryBusy;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
synchronized(_runtimeLock) {
if (_runtime != null)
return;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtimeMap.put(_runtime!!, this);
_runtimeMap.put(_runtime!!, this);
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
}
}
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
}
}
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
}
}
}
@@ -230,9 +218,6 @@ class V8Plugin {
if(pack is PackageHttp) {
pack.cleanup();
}
else if(pack is PackageBrowser) {
pack.deinitialize();
}
}
_runtime?.let {
@@ -260,30 +245,27 @@ class V8Plugin {
fun isThreadAlreadyBusy(): Boolean {
return _busyLock.isHeldByCurrentThread;
}
fun <T> busy(handle: ()->T): T = busyInternal(BUSY_FATAL_MS, true, "busy(enter)", handle)
fun <T> tryBusy(maxWaitMs: Long, handle: ()->T): T = busyInternal(maxWaitMs, false, "tryBusy(enter)", handle)
private fun <T> busyInternal(maxWaitMs: Long, allowUnwedge: Boolean, context: String, handle: ()->T): T {
acquireBusyOrThrow(context, maxWaitMs, allowUnwedge);
_busyHolder = Thread.currentThread();
fun <T> busy(handle: ()->T): T {
_busyLock.lock();
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
try {
return handle();
}
finally {
if (_busyLock.isHeldByCurrentThread) {
if (_busyLock.holdCount == 1)
_busyHolder = null;
_busyLock.unlock();
}
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
_busyLock.unlock();
}
/*
_busyLock.withLock {
//Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString());
return handle();
}*/
}
fun <T> unbusy(handle: ()->T): T {
val wasLocked = isThreadAlreadyBusy();
if(!wasLocked)
return handle();
val lockCount = _busyLock.holdCount;
_busyHolder = null;
for(i in 1..lockCount)
_busyLock.unlock();
try {
@@ -292,90 +274,9 @@ class V8Plugin {
}
finally {
Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise")
var acquired = 0;
try {
for (i in 1..lockCount) {
acquireBusyOrThrow("unbusy(relock)");
acquired++;
}
_busyHolder = Thread.currentThread();
}
catch (timeout: Throwable) {
for (j in 1..acquired)
_busyLock.unlock();
throw timeout;
}
}
}
private fun acquireBusyOrThrow(context: String, maxWaitMs: Long = BUSY_FATAL_MS, allowUnwedge: Boolean = true) {
val warnAt = Math.min(BUSY_WARN_MS, maxWaitMs);
if (_busyLock.tryLock(warnAt, TimeUnit.MILLISECONDS))
return;
logBusyContention(context);
val remaining = maxWaitMs - warnAt;
if (remaining > 0 && _busyLock.tryLock(remaining, TimeUnit.MILLISECONDS))
return;
if (!allowUnwedge)
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs}ms in $context (fast-fail)");
unwedgeBusyHolder(context);
if (_busyLock.tryLock(BUSY_RECOVERY_MS, TimeUnit.MILLISECONDS))
return;
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs + BUSY_RECOVERY_MS}ms in $context; holder did not release after recovery");
}
private fun unwedgeBusyHolder(context: String) {
val holder = _busyHolder;
Logger.w(TAG, "V8 busy lock for [${config.name}] still held in $context after ${BUSY_FATAL_MS}ms; attempting to unwedge holder ${holder?.name ?: "unknown"}");
try {
val rt = _runtime;
if (rt != null && !rt.isClosed && !rt.isDead) {
Logger.w(TAG, "Calling terminateExecution() on [${config.name}] runtime");
rt.terminateExecution();
}
}
catch (ex: Throwable) {
Logger.e(TAG, "terminateExecution() failed for [${config.name}]", ex);
}
try {
holder?.interrupt();
}
catch (ex: Throwable) {
Logger.e(TAG, "Interrupting holder thread for [${config.name}] failed", ex);
}
}
private fun logBusyContention(context: String) {
try {
val holder = _busyHolder;
val sb = StringBuilder();
sb.append("V8 BUSY CONTENTION [${config.name}] in $context: queueLength=${_busyLock.queueLength}, holdCount=${_busyLock.holdCount}, waited>${BUSY_WARN_MS}ms\n");
if (holder != null) {
sb.append("Lock holder: ${holder.name} (id=${holder.id}, state=${holder.state})\n");
for (frame in holder.stackTrace.take(40))
sb.append(" at ").append(frame.toString()).append("\n");
} else {
sb.append("Lock holder unknown (likely already released or never set)\n");
}
sb.append("Suspect waiting/blocked threads:\n");
val cur = Thread.currentThread();
for ((thread, stack) in Thread.getAllStackTraces()) {
if (thread == cur || thread == holder) continue;
if (thread.state != Thread.State.WAITING && thread.state != Thread.State.BLOCKED && thread.state != Thread.State.TIMED_WAITING) continue;
if (stack.none {
val cn = it.className;
cn.contains("V8Plugin") || cn.contains("JSClient") || cn.contains("Extensions_V8")
|| cn.contains("Subscription") || cn.contains("PackageHttp") || cn.contains("JSPager")
|| cn.contains("JSContent")
}) continue;
sb.append(" ${thread.name} (state=${thread.state}):\n");
for (frame in stack.take(20))
sb.append(" at ").append(frame.toString()).append("\n");
}
Logger.w(TAG, sb.toString());
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed to log busy contention", ex);
for(i in 1..lockCount)
_busyLock.lock();
}
}
fun execute(js: String) : V8Value {
@@ -486,18 +387,6 @@ class V8Plugin {
"HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config)
"Browser" -> {
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
if(BuildConfig.DEBUG)
PackageBrowser(this)
else if(isOfficial)
PackageBrowser(this)
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
PackageBrowser(this)
else
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
};
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
};
}
@@ -520,11 +409,6 @@ class V8Plugin {
val TAG = "V8Plugin";
private const val BUSY_WARN_MS = 10_000L;
private const val BUSY_FATAL_MS = 60_000L;
private const val BUSY_RECOVERY_MS = 5_000L;
const val BUSY_STARTUP_MS = 5_000L;
fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? {
return _runtimeMap.getOrDefault(runtime, null);
}
@@ -662,4 +546,4 @@ class V8Plugin {
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
}
}
}
}
@@ -17,7 +17,6 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
@@ -37,9 +36,6 @@ class PackageBridge : V8Package {
private val _client: ManagedHttpClient
@Transient
private val _clientAuth: ManagedHttpClient
// Set by JSClient after construction to provide access to auth/captcha data
@Transient
var descriptor: SourcePluginDescriptor? = null
override val name: String get() = "Bridge";
@@ -84,17 +80,6 @@ class PackageBridge : V8Package {
return "android";
}
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
@V8Property
fun captchaUserAgent(): String? {
return descriptor?.getCaptchaData()?.userAgent
}
@V8Property
fun authUserAgent(): String? {
return descriptor?.getAuth()?.userAgent
}
@V8Property
fun supportedFeatures(): Array<String> {
return arrayOf(
@@ -120,17 +105,10 @@ class PackageBridge : V8Package {
)
}
@V8Function
fun hasPackage(str: String): Boolean {
return _plugin.getPackages().any { it.name == str };
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
_plugin.busy {
value.close();
}
value.close();
}
var timeoutCounter = 0;
@@ -296,4 +274,4 @@ class PackageBridge : V8Package {
}
}
}
@@ -1,554 +0,0 @@
package com.futo.platformplayer.engine.packages
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
import androidx.core.net.toUri
class PackageBrowser: V8Package {
val useAddDocumentStartJavaScript = true
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Volatile private var _loadToken: String? = null
@Volatile private var _expectedMainUrl: String? = null
private val _json = Json { };
@Transient
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
@Transient
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
@Transient
private var _readySemaphore: Semaphore? = null;
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
throw IllegalStateException("Browser not initialized");
return _browser!!;
}
@Volatile
private var _userAgent: String = ""
private val http = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.build()
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
}
@V8Function
fun initialize() {
if (_browser != null) return
onMainBlocking {
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
if (!isHtml) return null
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
val cspHeader = resp.header("Content-Security-Policy")
?: resp.header("Content-Security-Policy-Report-Only")
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
try { cookieMgr.flush() } catch (_: Throwable) {}
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
}
} catch (_: Throwable) {
null
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
releaseReadyIfCurrent(url)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
releaseReadyIfCurrent(url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
try {
val raw = consoleMessage?.message().orEmpty()
val normalized = raw.trim().let { s ->
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
s.substring(1, s.length - 1)
} else s
}
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
if (handleConsoleBridgeMessage(payload)) return true
}
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val emsg =
"Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", emsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(
StateDeveloper.instance.currentDevID ?: "", emsg
)
} else {
val imsg = "Browser Log:${consoleMessage?.message()}"
Logger.i("PackageBrowser", imsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(
StateDeveloper.instance.currentDevID ?: "", imsg
)
}
return super.onConsoleMessage(consoleMessage)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle onConsoleMessage", e)
return super.onConsoleMessage(consoleMessage)
}
}
}
}
val bootstrap = """
(() => {
try {
if (window.__GJ) return;
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
const emit = (obj) => {
try {
console.info(PREFIX + JSON.stringify(obj));
} catch (_) {}
};
Object.defineProperty(window, "__GJ", {
value: {
callback: (id, result) => {
try {
const r = (typeof result === "string")
? result
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
emit({ t: "cb", id: String(id), result: r });
} catch (_) {}
},
log: (msg) => {
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
}
},
enumerable: false,
configurable: false,
writable: false
});
} catch (_) {}
})();
""".trimIndent()
addScriptOnLoad(bootstrap)
}
@V8Function
fun deinitialize() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_browser?.destroy();
}
_browser = null;
}
@V8Function
fun getCurrentUrl(): String? {
return browser.url;
}
@V8Function
fun waitTillLoaded(timeout: Int = 1000): Boolean {
val acquired = _readySemaphore?.let {
if(!it.tryAcquire()) {
Logger.i("PackageBrowser", "Waiting for browser to be ready");
if(!runBlocking {
try {
return@runBlocking withTimeout(timeout.toLong(), {
it.acquire()
return@withTimeout true;
});
}
catch(ex: TimeoutCancellationException) {
return@runBlocking false;
}
}) return@let false;
}
it.release();
return@let true;
} ?: true;
if(acquired)
Logger.i("PackageBrowser", "Browser is ready");
else
Logger.i("PackageBrowser", "Browser failed wait ready");
return acquired;
}
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [$url]")
val token = UUID.randomUUID().toString()
_loadToken = token
_expectedMainUrl = url
_readySemaphore = Semaphore(1, acquiredPermits = 1)
StateApp.instance.scope.launch(Dispatchers.Main) {
try { browser.loadUrl(url) }
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
}
}
private fun releaseReadyIfCurrent(url: String?) {
if (url == null) return
val expected = _expectedMainUrl
if (url.trimEnd('/') != expected?.trimEnd('/')) return
_readySemaphore?.release()
_readySemaphore = null
_expectedMainUrl = null
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
if(callbackId != null && callback != null) {
synchronized(_callbacks) {
_callbacks.put(callbackId, {
_plugin.busy {
funcClone?.callVoid(null, arrayOf(it));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
}
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
}
}
}
@V8Function
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
try {
_plugin.busy {
if (value != null) {
val json = _json.decodeFromString<String>(value);
funcClone?.callVoid(null, arrayOf(json));
} else
funcClone?.callVoid(null, arrayOf((null as String?)));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
}
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
}
}
}
@V8Function
fun addScriptOnLoad(js: String): String {
require(js.isNotBlank()) { "Script must be non-empty." }
val id = UUID.randomUUID().toString()
onMainBlocking {
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
_pageLoadScriptRefs[id] = ref
} else {
_pageLoadScriptsFallback[id] = js
}
}
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
return id
}
@SuppressLint("RequiresFeature")
@V8Function
fun removeScriptOnLoad(identifier: String): Boolean {
if (identifier.isBlank()) return false
val ref = _pageLoadScriptRefs.remove(identifier)
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
if (ref != null) {
onMainBlocking {
try { ref.remove() } catch (_: Throwable) {}
}
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
return true
}
if (removedFallback) {
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
return true
}
return false
}
@SuppressLint("RequiresFeature")
@V8Function
fun clearScriptsOnLoad() {
val refs = _pageLoadScriptRefs.values.toList()
_pageLoadScriptRefs.clear()
_pageLoadScriptsFallback.clear()
onMainBlocking {
for (r in refs) {
try { r.remove() } catch (_: Throwable) {}
}
}
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
}
private fun charsetFromContentType(ct: String): Charset? {
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
return runCatching { Charset.forName(name) }.getOrNull()
}
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
val tag = "<script$nonceAttr>\n$js\n</script>\n"
val head = Regex("(?i)<head[^>]*>").find(html)
if (head != null) {
val i = head.range.last + 1
return buildString(html.length + tag.length + 8) {
append(html, 0, i)
append('\n')
append(tag)
append(html, i, html.length)
}
}
return tag + html
}
private fun <T> onMainBlocking(block: () -> T): T {
return if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else runBlocking {
withContext(Dispatchers.Main) { block() }
}
}
private fun extractNonceFromCsp(csp: String?): String? {
if (csp.isNullOrBlank()) return null
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
return m.groupValues[1]
}
private fun extractNonceFromHtml(html: String): String? {
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
return m?.groupValues?.get(1)
}
private fun escapeHtmlAttr(s: String): String =
s.replace("&", "&amp;").replace("\"", "&quot;")
@Serializable
private data class ConsoleBridgeMsg(
val t: String,
val id: String? = null,
val result: String? = null,
val msg: String? = null
)
private fun handleConsoleBridgeMessage(payload: String): Boolean {
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
?: return false
when (parsed.t) {
"cb" -> {
val id = parsed.id ?: return true
val res = parsed.result
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
cb.invoke(res)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke callback asynchronously", e)
}
}
return true
}
"log" -> {
val text = parsed.msg.orEmpty()
Logger.i("PackageBrowser", "Browser Log: $text")
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
}
return true
}
else -> return true
}
}
private companion object {
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
private const val TAG = "PackageBrowser"
private fun String.quoteForJs(): String {
val s = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$s\""
}
}
}
@@ -651,17 +651,14 @@ class PackageHttp: V8Package {
@V8Function
fun connect(socketObj: V8ValueObject) {
val (hasOpen, hasMessage, hasClosing, hasClosed, hasFailure) = _package._plugin.busy {
val open = socketObj.has("open");
val message = socketObj.has("message");
val closing = socketObj.has("closing");
val closed = socketObj.has("closed");
val failure = socketObj.has("failure");
val hasOpen = socketObj.has("open");
val hasMessage = socketObj.has("message");
val hasClosing = socketObj.has("closing");
val hasClosed = socketObj.has("closed");
val hasFailure = socketObj.has("failure");
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
Quintuple(open, message, closing, closed, failure);
};
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
_socket = _packageClient.logExceptions {
val client = _client;
@@ -669,50 +666,51 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
try {
_package._plugin.busy {
if(hasOpen && _listeners?.isClosed != true) {
if(hasOpen && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
_listeners?.invokeV8Void("open", arrayOf<Any>());
}
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
}
override fun message(msg: String) {
try {
_package._plugin.busy {
if(hasMessage && _listeners?.isClosed != true) {
if(hasMessage && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
_listeners?.invokeV8Void("message", msg);
}
}
catch(ex: Throwable) {}
}
catch(ex: Throwable) {}
}
override fun closing(code: Int, reason: String) {
try {
_package._plugin.busy {
if(hasClosing && _listeners?.isClosed != true) {
if(hasClosing && _listeners?.isClosed != true)
{
try {
_package._plugin.busy {
_listeners?.invokeV8Void("closing", code, reason);
}
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
try {
_package._plugin.busy {
if(hasClosed && _listeners?.isClosed != true) {
if(hasClosed && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
_listeners?.invokeV8Void("closed", code, reason);
}
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
@@ -722,15 +720,15 @@ class PackageHttp: V8Package {
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
try {
_package._plugin.busy {
if(hasFailure && _listeners?.isClosed != true) {
if(hasFailure && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
_listeners?.invokeV8Void("failure", exception.message);
}
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
}
});
@@ -749,20 +747,10 @@ class PackageHttp: V8Package {
@V8Function
fun close(code: Int?, reason: String?) {
_socket?.close(code ?: 1000, reason ?: "");
_package._plugin.busy {
_listeners?.close()
}
_listeners?.close()
}
}
private data class Quintuple<A, B, C, D, E>(
val first: A,
val second: B,
val third: C,
val fourth: D,
val fifth: E
)
data class RequestDescriptor(
val method: String,
val url: String,
@@ -792,4 +780,4 @@ class PackageHttp: V8Package {
private const val TAG = "PackageHttp";
private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection")
}
}
}
@@ -367,7 +367,7 @@ class ArticleDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this@ArticleDetailView) { args ->
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -10,7 +10,6 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -22,7 +21,6 @@ class BrowserFragment : MainFragment() {
override val isTab: Boolean = false;
override val hasBottomBar: Boolean get() = true;
private var _root: LinearLayout? = null;
private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
@@ -33,7 +31,6 @@ class BrowserFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_browser, container, false);
_root = view.findViewById<LinearLayout>(R.id.root);
_webview = view.findViewById<WebView?>(R.id.webview).apply {
this.webViewClient = _webviewWithoutHandling;
this.settings.javaScriptEnabled = true;
@@ -46,12 +43,7 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
if(parameter is WebView) {
_root?.removeView(_webview);
_root?.addView(parameter);
_webview = parameter;
}
else if(parameter is String) {
if(parameter is String) {
_webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter);
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -14,8 +16,6 @@ import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -68,11 +68,8 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
}, UIDialogs.ActionStyle.PRIMARY)
);
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
}
else {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
@@ -92,19 +89,16 @@ class BuyFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
//Calling this function will cache first call
try {
// TODO: Restore multi-currency support when payment backend supports it
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// if(currency != null && prices.containsKey(currency.id)) {
// val price = prices[currency.id]!!;
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
// }
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
}
}
catch(ex: Throwable) {
@@ -171,4 +165,4 @@ class BuyFragment : MainFragment() {
fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
}
}
}
@@ -27,7 +27,6 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -216,10 +215,6 @@ class ChannelFragment : MainFragment() {
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -150,7 +150,7 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc", "typeAudio", "typeVideo");
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
@@ -162,8 +162,6 @@ class DownloadsFragment : MainFragment() {
5 -> ordering.setAndSave("releasedDesc")
6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc")
8 -> ordering.setAndSave("typeAudio")
9 -> ordering.setAndSave("typeVideo")
else -> ordering.setAndSave("")
}
updateContentFilters()
@@ -263,8 +261,6 @@ class DownloadsFragment : MainFragment() {
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"typeAudio" -> vidsToReturn.sortedBy { if (it.videoSource.isEmpty() && it.audioSource.isNotEmpty()) 0 else 1 }
"typeVideo" -> vidsToReturn.sortedBy { if (it.videoSource.isNotEmpty()) 0 else 1 }
else -> vidsToReturn
}
}
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _progressBar: ProgressBar;
private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout;
//private val _announcementView: AnnouncementView;
private val _announcementView: AnnouncementView;
private val _tagsView: TagsView;
private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout;
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
//_announcementView = findViewById(R.id.announcement_view)
_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh);
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
protected fun showAnnouncementView() {
//_announcementView.visibility = View.VISIBLE
_announcementView.visibility = View.VISIBLE
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
@@ -28,7 +28,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
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.playlists.IPlatformPlaylist
@@ -177,10 +176,6 @@ class LibraryArtistFragment : MainFragment() {
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
});
if(this.allowMusic) {
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
adapterArtists.setData(artists);
if (artists.size == 0)
sectionArtists.setEmpty(
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
}
if(this.allowMusic) {
val albums = StateLibrary.instance.getAlbums()
val albums = StateLibrary.instance.getAlbums();
adapterAlbums.setData(albums);
if (albums.size == 0)
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
@@ -96,15 +96,11 @@ class LoginFragment : MainFragment() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -370,7 +370,7 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this@PostDetailView) { args ->
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -688,7 +688,6 @@ class ShortView : FrameLayout {
dislikeButton.visibility = GONE
loadLikesTask?.cancel()
onLikeDislikeUpdated.remove(this@ShortView)
loadLikesTask =
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
StateApp.instance.scopeGetter, {
@@ -716,7 +715,7 @@ class ShortView : FrameLayout {
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
onLikeDislikeUpdated.subscribe(this@ShortView) { args ->
onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like)
} else if (args.hasDisliked) {
@@ -309,14 +309,13 @@ class SourceDetailFragment : MainFragment() {
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource();
},
if(!Settings.instance.plugins.shouldClearWebviewCookies())
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false);
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} else null
}
)
);
@@ -519,17 +518,6 @@ class SourceDetailFragment : MainFragment() {
}
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
}
finally {
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to clear cookies", ex);
}
}
}
};
}, UIDialogs.ActionStyle.PRIMARY))
}
@@ -459,14 +459,7 @@ class VideoDetailFragment() : MainFragment() {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
try {
activity?.enterPictureInPictureMode(params);
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
activity?.enterPictureInPictureMode(params);
}
}
@@ -477,15 +470,8 @@ class VideoDetailFragment() : MainFragment() {
fun forcePictureInPicture() {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
try {
activity?.enterPictureInPictureMode(params);
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
}
if(params != null)
activity?.enterPictureInPictureMode(params);
}
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) {
try {
@@ -33,7 +33,6 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.ui.text.toLowerCase
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
@@ -216,7 +215,6 @@ class VideoDetailView : ConstraintLayout {
private val _playerProgress: PlayerControlView;
private val _timeBar: TimeBar;
private var _upNext: UpNextView;
private var _artworkTarget: CustomTarget<Bitmap>? = null
private val rootView: ConstraintLayout;
@@ -694,14 +692,7 @@ class VideoDetailView : ConstraintLayout {
onShouldEnterPictureInPictureChanged.subscribe {
val params = getPictureInPictureParams()
try {
fragment.activity?.setPictureInPictureParams(params)
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
fragment.activity?.setPictureInPictureParams(params)
}
if (!isInEditMode) {
@@ -732,17 +723,15 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
};
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -890,9 +879,6 @@ class VideoDetailView : ConstraintLayout {
};
onClose.subscribe {
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
checkAndRemoveWatchLater();
_lastVideoSource = null;
_lastAudioSource = null;
@@ -905,7 +891,6 @@ class VideoDetailView : ConstraintLayout {
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
clearChapters()
};
StatePlayer.instance.autoplayChanged.subscribe(this) {
@@ -1000,12 +985,6 @@ class VideoDetailView : ConstraintLayout {
_cast.stopAllGestures();
}
private fun clearChapters() {
_chapters = null
_player.setChapters(null)
_cast.setChapters(null)
}
fun showChaptersUI(){
video?.let {
try {
@@ -1213,7 +1192,6 @@ class VideoDetailView : ConstraintLayout {
else if(_didStop) {
_didStop = false;
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
StatePlayer.instance.startOrUpdateMediaSession(context, video);
loadCurrentVideo(lastPositionMilliseconds);
handlePause();
}
@@ -1283,9 +1261,6 @@ class VideoDetailView : ConstraintLayout {
fun onDestroy() {
Logger.i(TAG, "onDestroy");
_destroyed = true;
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
_taskLoadVideo.cancel();
_commentsList.cancel();
_player.clear();
@@ -1298,7 +1273,6 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this);
@@ -1337,7 +1311,6 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null;
_lastAudioSource = null;
_lastSubtitleSource = null;
clearChapters()
}
fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) {
Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady")
@@ -1348,7 +1321,6 @@ class VideoDetailView : ConstraintLayout {
_searchVideo = null;
video = null;
cleanupPlaybackTracker();
clearChapters()
_url = url;
_videoResumePositionMilliseconds = resumeSeconds * 1000;
_rating.visibility = View.GONE;
@@ -1559,7 +1531,6 @@ class VideoDetailView : ConstraintLayout {
}
val me = this;
clearChapters()
if (video is JSVideoDetails) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -1756,7 +1727,7 @@ class VideoDetailView : ConstraintLayout {
hasLiked,
hasDisliked
);
_rating.onLikeDislikeUpdated.subscribe(this@VideoDetailView) { args ->
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -2078,31 +2049,19 @@ class VideoDetailView : ConstraintLayout {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
} else {
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
val thumbnail = video.thumbnails.getHQThumbnail()
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
if (showArtwork && !thumbnail.isNullOrBlank()) {
val target = object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource))
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null)
}
}
_artworkTarget = target
Glide.with(context)
.asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(target)
} else {
_player.setArtwork(null)
}
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null);
}
});
else
_player.setArtwork(null);
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -2461,54 +2420,9 @@ class VideoDetailView : ConstraintLayout {
val doDedup = Settings.instance.playback.simplifySources;
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
Log.i(TAG, "Language count: ${allLanguages}");
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
?.distinct()
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
?.distinct()
?.filterNotNull()
@@ -2524,7 +2438,7 @@ class VideoDetailView : ConstraintLayout {
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, false,
R.string.quality), null, true,
qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -2614,10 +2528,11 @@ class VideoDetailView : ConstraintLayout {
call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray())
else null,
if(languageFilters != null) languageFilters else null,
if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
(bestVideoSources.map {
*bestVideoSources
.map {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context,
@@ -2626,14 +2541,8 @@ class VideoDetailView : ConstraintLayout {
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(),
tag = it,
call = { handleSelectVideoTrack(it) }).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
};
}).toList())
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
else null,
if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
@@ -7,9 +7,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
@@ -20,54 +17,18 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.views.casting.CastButton
import com.futo.platformplayer.views.notification.NotificationOverlayView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GeneralTopBarFragment : TopFragment() {
private var _buttonSearch: ImageButton? = null;
private var _buttonCast: CastButton? = null;
private var _buttonNotifs: ConstraintLayout? = null;
private var _buttonNotifIcon: ImageView? = null;
private var _buttonNotifCount: TextView? = null;
init {
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
lifecycleScope?.launch(Dispatchers.Main) {
updateNotifCount();
}
}
}
fun updateNotifCount() {
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
if(currentAnnouncements.any())
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.VISIBLE;
}
else
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.GONE;
}
}
override fun onShown(parameter: Any?) {
if(currentMain is CreatorsFragment) {
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
} else {
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
}
if(currentMain is NotificationOverlayView.Frag) {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
}
else {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
}
}
override fun onHide() {
@@ -83,19 +44,6 @@ class GeneralTopBarFragment : TopFragment() {
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
_buttonCast = view.findViewById(R.id.button_cast);
_buttonNotifs = view.findViewById(R.id.button_notifs);
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
updateNotifCount();
_buttonNotifs?.setOnClickListener {
if(currentMain is NotificationOverlayView.Frag)
closeSegment();
else
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener {
if(currentMain is CreatorsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
@@ -1,35 +1,18 @@
package com.futo.platformplayer.helpers
import java.text.Normalizer
class FileHelper {
companion object {
fun String.sanitizeFileName(allowSpace: Boolean = false): String {
val normalized = Normalizer.normalize(this, Normalizer.Form.NFC)
val cleaned = buildString(normalized.length) {
for (ch in normalized) {
when {
ch == '\u0000' -> {}
Character.isISOControl(ch) -> {}
ch == '/' || ch == '\\' || ch == ':' || ch == '*' ||
ch == '?' || ch == '"' || ch == '<' || ch == '>' || ch == '|' -> append('_')
ch == ' ' && !allowSpace -> append('_')
else -> append(ch)
}
}
}
val collapsed = if (allowSpace) {
cleaned.replace(Regex("""\s+"""), " ")
} else {
cleaned.replace(Regex("""\s+"""), "_")
}
return collapsed
.trim()
.trimEnd('.')
return this.filter {
(it in '0' .. '9') ||
(it in 'a'..'z') ||
(it in 'A'..'Z') ||
(it == '-' || it == '.' || it == '_' || (it == ' ' && allowSpace)) ||
(it in '丁'..'龤') || //Chinese/Kanji
(it in '\u3040'..'\u309f') || //Hiragana
(it in '\u30A0'..'\u30ff') || //Katakana
(it in '\u0600'..'\u06FF') //Arabic
}; //Chinese
}
}
}
@@ -52,8 +52,8 @@ class VideoHelper {
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
val targetVideo = if(desiredPixelCount > 0) {
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
} else {
@@ -63,34 +63,12 @@ class VideoHelper {
val hasPriority = sources.any { it.priority };
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
//Filter priority
var altSources = if(hasPriority) {
val altSources = if(hasPriority) {
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
} else {
sources.filter { it.height == (targetVideo?.height ?: 0) };
}
//Filter Original
val hasOriginal = altSources.any { it.original == true };
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
altSources = altSources.filter { it.original == true };
//Filter Language
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
preferredLanguage
} else {
if(altSources.any { it.language == Language.ENGLISH })
Language.ENGLISH;
else
Language.UNKNOWN;
}
if(altSources.any { it.language == languageToFilter }) {
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
} else {
altSources.sortedBy { it.bitrate }
}
var bestSource = altSources.firstOrNull();
for (prefContainer in prefContainers) {
val betterSource = altSources.firstOrNull { it.container == prefContainer };
@@ -16,15 +16,13 @@ class CaptchaWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _captchaConfig: SourcePluginCaptchaConfig;
private val _userAgent: String?;
private var _didNotify = false;
private val _extractor: WebViewRequirementExtractor;
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
constructor(config: SourcePluginConfig) : super() {
_pluginConfig = config;
_captchaConfig = config.captcha!!;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
config.allowUrls,
null,
@@ -36,10 +34,9 @@ class CaptchaWebViewClient : WebViewClient {
Logger.i(TAG, "Captcha [${config.name}]" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
}
constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
constructor(captcha: SourcePluginCaptchaConfig) : super() {
_pluginConfig = null;
_captchaConfig = captcha;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
null,
null,
@@ -65,8 +62,7 @@ class CaptchaWebViewClient : WebViewClient {
_didNotify = true;
onCaptchaFinished.emit(SourceCaptchaData(
extracted.cookies,
extracted.headers,
_userAgent
extracted.headers
));
}
@@ -24,26 +24,23 @@ class LoginWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _authConfig: SourcePluginAuthConfig;
private val _userAgent: String?;
private val _client = ManagedHttpClient();
val onLogin = Event1<SourceAuth>();
val onPageLoaded = Event2<WebView?, String?>()
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
constructor(config: SourcePluginConfig) : super() {
_pluginConfig = config;
_authConfig = config.authentication!!;
_userAgent = userAgent;
Logger.i(TAG, "Login [${config.name}]" +
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
}
constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
constructor(auth: SourcePluginAuthConfig) : super() {
_pluginConfig = null;
_authConfig = auth;
_userAgent = userAgent;
}
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
@@ -195,14 +192,13 @@ class LoginWebViewClient : WebViewClient {
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
onLogin.emit(SourceAuth(
cookieMap = cookiesFoundMap,
headers = headersFoundMap, /*.associate { headerToFind ->
headers = headersFoundMap /*.associate { headerToFind ->
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
requestHeader.value
else null;
}
} ?: mapOf()*/
userAgent = _userAgent
));
}
@@ -0,0 +1,124 @@
package com.futo.platformplayer.sabr;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
class CombinedQueryString implements UrlQueryString {
private final List<UrlQueryString> mQueryStrings = new ArrayList<>();
public CombinedQueryString(String url) {
UrlQueryString urlQueryString = UrlEncodedQueryString.parse(url);
if (urlQueryString.isValid()) {
mQueryStrings.add(urlQueryString);
}
UrlQueryString pathQueryString = PathQueryString.parse(url);
if (pathQueryString.isValid()) {
mQueryStrings.add(pathQueryString);
}
if (mQueryStrings.isEmpty()) {
mQueryStrings.add(NullQueryString.parse(url));
}
}
public static UrlQueryString parse(String url) {
return new CombinedQueryString(url);
}
@Override
public void remove(String key) {
for (UrlQueryString queryString : mQueryStrings) {
queryString.remove(key);
}
}
@Override
public String get(String key) {
for (UrlQueryString queryString : mQueryStrings) {
String value = queryString.get(key);
if (value != null) {
return value;
}
}
return null;
}
@Override
public float getFloat(String key) {
for (UrlQueryString queryString : mQueryStrings) {
float value = queryString.getFloat(key);
if (value != 0) {
return value;
}
}
return 0;
}
@Override
public void set(String key, String value) {
for (UrlQueryString queryString : mQueryStrings) {
queryString.set(key, value);
}
}
@Override
public void set(String key, int value) {
for (UrlQueryString queryString : mQueryStrings) {
queryString.set(key, value);
}
}
@Override
public void set(String key, float value) {
for (UrlQueryString queryString : mQueryStrings) {
queryString.set(key, value);
}
}
@Override
public boolean isEmpty() {
for (UrlQueryString queryString : mQueryStrings) {
return queryString.isEmpty();
}
return true;
}
@Override
public boolean isValid() {
for (UrlQueryString queryString : mQueryStrings) {
return queryString.isValid();
}
return false;
}
@Override
public boolean contains(String key) {
for (UrlQueryString queryString : mQueryStrings) {
boolean contains = queryString.contains(key);
if (contains) {
return true;
}
}
return false;
}
@NonNull
@Override
public String toString() {
for (UrlQueryString queryString : mQueryStrings) {
return queryString.toString();
}
return super.toString();
}
}
@@ -0,0 +1,873 @@
package com.futo.platformplayer.sabr;
import static androidx.media3.common.util.Util.addWithOverflowDefault;
import static androidx.media3.common.util.Util.subtractWithOverflowDefault;
import android.net.Uri;
import android.os.SystemClock;
import androidx.annotation.CheckResult;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.LoadingInfo;
import androidx.media3.exoplayer.SeekParameters;
import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor;
import androidx.media3.exoplayer.source.chunk.ChunkExtractor;
import androidx.media3.extractor.ChunkIndex;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.exoplayer.source.BehindLiveWindowException;
import androidx.media3.exoplayer.source.chunk.BaseMediaChunkIterator;
import androidx.media3.exoplayer.source.chunk.Chunk;
import androidx.media3.exoplayer.source.chunk.ChunkHolder;
import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk;
import androidx.media3.exoplayer.source.chunk.InitializationChunk;
import androidx.media3.exoplayer.source.chunk.MediaChunk;
import androidx.media3.exoplayer.source.chunk.MediaChunkIterator;
import androidx.media3.exoplayer.source.chunk.SingleSampleMediaChunk;
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
import com.futo.platformplayer.sabr.manifest.RangedUri;
import com.futo.platformplayer.sabr.manifest.Representation;
import com.futo.platformplayer.sabr.manifest.SabrManifest;
import com.futo.platformplayer.sabr.parser.SabrExtractor;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException;
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
import androidx.media3.datasource.TransferListener;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@UnstableApi
public class DefaultSabrChunkSource implements SabrChunkSource {
public static final class Factory implements SabrChunkSource.Factory {
private final DataSource.Factory dataSourceFactory;
private final int maxSegmentsPerLoad;
public Factory(DataSource.Factory dataSourceFactory) {
this(dataSourceFactory, 1);
}
public Factory(DataSource.Factory dataSourceFactory, int maxSegmentsPerLoad) {
this.dataSourceFactory = dataSourceFactory;
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
}
@Override
public SabrChunkSource createSabrChunkSource(
LoaderErrorThrower manifestLoaderErrorThrower,
SabrManifest manifest,
int periodIndex,
int[] adaptationSetIndices,
ExoTrackSelection trackSelection,
int type,
long elapsedRealtimeOffsetMs,
boolean enableEventMessageTrack,
List<Format> closedCaptionFormats,
@Nullable PlayerTrackEmsgHandler playerEmsgHandler,
@Nullable TransferListener transferListener) {
DataSource dataSource = dataSourceFactory.createDataSource();
if (transferListener != null) {
dataSource.addTransferListener(transferListener);
}
return new DefaultSabrChunkSource(
manifestLoaderErrorThrower,
manifest,
periodIndex,
adaptationSetIndices,
trackSelection,
type,
dataSource,
elapsedRealtimeOffsetMs,
maxSegmentsPerLoad,
enableEventMessageTrack,
closedCaptionFormats,
playerEmsgHandler);
}
}
private final LoaderErrorThrower manifestLoaderErrorThrower;
private final int[] adaptationSetIndices;
private final int trackType;
private final DataSource dataSource;
private final long elapsedRealtimeOffsetMs;
private final int maxSegmentsPerLoad;
@Nullable private final PlayerTrackEmsgHandler playerTrackEmsgHandler;
protected final RepresentationHolder[] representationHolders;
private ExoTrackSelection trackSelection;
private SabrManifest manifest;
private int periodIndex;
private IOException fatalError;
private boolean missingLastSegment;
private long liveEdgeTimeUs;
/**
* @param manifestLoaderErrorThrower Throws errors affecting loading of manifests.
* @param manifest The initial manifest.
* @param periodIndex The index of the period in the manifest.
* @param adaptationSetIndices The indices of the adaptation sets in the period.
* @param trackSelection The track selection.
* @param trackType The type of the tracks in the selection.
* @param dataSource A {@link DataSource} suitable for loading the media data.
* @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between
* server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified
* as the server's unix time minus the local elapsed time. If unknown, set to 0.
* @param maxSegmentsPerLoad The maximum number of segments to combine into a single request. Note
* that segments will only be combined if their {@link Uri}s are the same and if their data
* ranges are adjacent.
* @param enableEventMessageTrack Whether to output an event message track.
* @param closedCaptionFormats The {@link Format Formats} of closed caption tracks to be output.
* @param playerTrackEmsgHandler The {@link PlayerTrackEmsgHandler} instance to handle emsg
* messages targeting the player. Maybe null if this is not necessary.
*/
public DefaultSabrChunkSource(
LoaderErrorThrower manifestLoaderErrorThrower,
SabrManifest manifest,
int periodIndex,
int[] adaptationSetIndices,
ExoTrackSelection trackSelection,
int trackType,
DataSource dataSource,
long elapsedRealtimeOffsetMs,
int maxSegmentsPerLoad,
boolean enableEventMessageTrack,
List<Format> closedCaptionFormats,
@Nullable PlayerTrackEmsgHandler playerTrackEmsgHandler) {
this.manifestLoaderErrorThrower = manifestLoaderErrorThrower;
this.manifest = manifest;
this.adaptationSetIndices = adaptationSetIndices;
this.trackSelection = trackSelection;
this.trackType = trackType;
this.dataSource = dataSource;
this.periodIndex = periodIndex;
this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs;
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
this.playerTrackEmsgHandler = playerTrackEmsgHandler;
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
liveEdgeTimeUs = C.TIME_UNSET;
List<Representation> representations = getRepresentations();
representationHolders = new RepresentationHolder[trackSelection.length()];
for (int i = 0; i < representationHolders.length; i++) {
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
representationHolders[i] =
new RepresentationHolder(
periodDurationUs,
trackType,
representation,
enableEventMessageTrack,
closedCaptionFormats,
playerTrackEmsgHandler);
}
}
@Override
public void updateManifest(SabrManifest newManifest, int newPeriodIndex) {
try {
manifest = newManifest;
periodIndex = newPeriodIndex;
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
List<Representation> representations = getRepresentations();
for (int i = 0; i < representationHolders.length; i++) {
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
representationHolders[i] =
representationHolders[i].copyWithNewRepresentation(periodDurationUs, representation);
}
} catch (BehindLiveWindowException e) {
fatalError = e;
}
}
@Override
public void updateTrackSelection(ExoTrackSelection trackSelection) {
this.trackSelection = trackSelection;
}
/**
* Resolves a seek given the requested seek position, a {@link SeekParameters} and two candidate
* sync points.
*
* @param positionUs The requested seek position, in microseocnds.
* @param seekParameters The {@link SeekParameters}.
* @param firstSyncUs The first candidate seek point, in micrseconds.
* @param secondSyncUs The second candidate seek point, in microseconds. May equal {@code
* firstSyncUs} if there's only one candidate.
* @return The resolved seek position, in microseconds.
*/
public static long resolveSeekPositionUs(
long positionUs, SeekParameters seekParameters, long firstSyncUs, long secondSyncUs) {
if (SeekParameters.EXACT.equals(seekParameters)) {
return positionUs;
}
long minPositionUs = subtractWithOverflowDefault(positionUs, seekParameters.toleranceBeforeUs, Long.MIN_VALUE);
long maxPositionUs = addWithOverflowDefault(positionUs, seekParameters.toleranceAfterUs, Long.MAX_VALUE);
boolean firstSyncPositionValid = minPositionUs <= firstSyncUs && firstSyncUs <= maxPositionUs;
boolean secondSyncPositionValid =
minPositionUs <= secondSyncUs && secondSyncUs <= maxPositionUs;
if (firstSyncPositionValid && secondSyncPositionValid) {
if (Math.abs(firstSyncUs - positionUs) <= Math.abs(secondSyncUs - positionUs)) {
return firstSyncUs;
} else {
return secondSyncUs;
}
} else if (firstSyncPositionValid) {
return firstSyncUs;
} else if (secondSyncPositionValid) {
return secondSyncUs;
} else {
return minPositionUs;
}
}
@Override
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
// Segments are aligned across representations, so any segment index will do.
for (RepresentationHolder representationHolder : representationHolders) {
if (representationHolder.segmentIndex != null) {
long segmentNum = representationHolder.getSegmentNum(positionUs);
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
long secondSyncUs =
firstSyncUs < positionUs && segmentNum < representationHolder.getSegmentCount() - 1
? representationHolder.getSegmentStartTimeUs(segmentNum + 1)
: firstSyncUs;
return resolveSeekPositionUs(positionUs, seekParameters, firstSyncUs, secondSyncUs);
}
}
// We don't have a segment index to adjust the seek position with yet.
return positionUs;
}
@Override
public void maybeThrowError() throws IOException {
if (fatalError != null) {
throw fatalError;
} else {
manifestLoaderErrorThrower.maybeThrowError();
}
}
@Override
public int getPreferredQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue) {
if (fatalError != null || trackSelection.length() < 2) {
return queue.size();
}
return trackSelection.evaluateQueueSize(playbackPositionUs, queue);
}
@Override
public void getNextChunk(LoadingInfo loadingInfo, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
//public void getNextChunk(long playbackPositionUs, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
if (fatalError != null) {
return;
}
long bufferedDurationUs = loadPositionUs - loadingInfo.playbackPositionUs;
long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(loadingInfo.playbackPositionUs);
long presentationPositionUs = C.msToUs(manifest.availabilityStartTimeMs)
+ C.msToUs(manifest.getPeriod(periodIndex).startMs)
+ loadPositionUs;
if (playerTrackEmsgHandler != null
&& playerTrackEmsgHandler.maybeRefreshManifestBeforeLoadingNextChunk(
presentationPositionUs)) {
return;
}
long nowUnixTimeUs = getNowUnixTimeUs();
MediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1);
MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()];
for (int i = 0; i < chunkIterators.length; i++) {
RepresentationHolder representationHolder = representationHolders[i];
if (representationHolder.segmentIndex == null) {
chunkIterators[i] = MediaChunkIterator.EMPTY;
} else {
long firstAvailableSegmentNum =
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
long lastAvailableSegmentNum =
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
long segmentNum =
getSegmentNum(
representationHolder,
previous,
loadPositionUs,
firstAvailableSegmentNum,
lastAvailableSegmentNum);
if (segmentNum < firstAvailableSegmentNum) {
chunkIterators[i] = MediaChunkIterator.EMPTY;
} else {
chunkIterators[i] =
new RepresentationSegmentIterator(
representationHolder, segmentNum, lastAvailableSegmentNum);
}
}
}
trackSelection.updateSelectedTrack(
loadingInfo.playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, chunkIterators);
RepresentationHolder representationHolder =
representationHolders[trackSelection.getSelectedIndex()];
if (representationHolder.extractorWrapper != null) {
Representation selectedRepresentation = representationHolder.representation;
RangedUri pendingInitializationUri = null;
RangedUri pendingIndexUri = null;
if (representationHolder.extractorWrapper.getSampleFormats() == null) {
pendingInitializationUri = selectedRepresentation.getInitializationUri();
}
if (representationHolder.segmentIndex == null) {
pendingIndexUri = selectedRepresentation.getIndexUri();
}
if (pendingInitializationUri != null || pendingIndexUri != null) {
// We have initialization and/or index requests to make.
out.chunk = newInitializationChunk(representationHolder, dataSource,
trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(),
trackSelection.getSelectionData(), pendingInitializationUri, pendingIndexUri);
return;
}
}
long periodDurationUs = representationHolder.periodDurationUs;
boolean periodEnded = periodDurationUs != C.TIME_UNSET;
if (representationHolder.getSegmentCount() == 0) {
// The index doesn't define any segments.
out.endOfStream = periodEnded;
return;
}
long firstAvailableSegmentNum =
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
long lastAvailableSegmentNum =
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
updateLiveEdgeTimeUs(representationHolder, lastAvailableSegmentNum);
long segmentNum =
getSegmentNum(
representationHolder,
previous,
loadPositionUs,
firstAvailableSegmentNum,
lastAvailableSegmentNum);
if (segmentNum < firstAvailableSegmentNum) {
// This is before the first chunk in the current manifest.
fatalError = new BehindLiveWindowException();
return;
}
if (segmentNum > lastAvailableSegmentNum
|| (missingLastSegment && segmentNum >= lastAvailableSegmentNum)) {
// The segment is beyond the end of the period.
out.endOfStream = periodEnded;
return;
}
if (periodEnded && representationHolder.getSegmentStartTimeUs(segmentNum) >= periodDurationUs) {
// The period duration clips the period to a position before the segment.
out.endOfStream = true;
return;
}
int maxSegmentCount =
(int) Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1);
if (periodDurationUs != C.TIME_UNSET) {
while (maxSegmentCount > 1
&& representationHolder.getSegmentStartTimeUs(segmentNum + maxSegmentCount - 1)
>= periodDurationUs) {
// The period duration clips the period to a position before the last segment in the range
// [segmentNum, segmentNum + maxSegmentCount - 1]. Reduce maxSegmentCount.
maxSegmentCount--;
}
}
long seekTimeUs = queue.isEmpty() ? loadPositionUs : C.TIME_UNSET;
out.chunk =
newMediaChunk(
representationHolder,
dataSource,
trackType,
trackSelection.getSelectedFormat(),
trackSelection.getSelectionReason(),
trackSelection.getSelectionData(),
segmentNum,
maxSegmentCount,
seekTimeUs);
}
@Override
public boolean shouldCancelLoad(
long playbackPositionUs, Chunk loadingChunk, List<? extends MediaChunk> queue) {
if (fatalError != null || trackSelection.length() < 2) {
return false;
}
// Let the selection decide (Media3 exposes this).
return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, (List<MediaChunk>) queue);
}
@Override
public void onChunkLoadCompleted(Chunk chunk) {
// If the init chunk just finished, try to grab a parsed ChunkIndex from the extractor.
if (chunk instanceof InitializationChunk) {
final int trackIndex = trackSelection.indexOf(chunk.trackFormat);
if (trackIndex != C.INDEX_UNSET) {
RepresentationHolder holder = representationHolders[trackIndex];
// Don't overwrite a manifest-defined index. Only adopt stream-provided index if needed.
if (holder.segmentIndex == null && holder.extractorWrapper != null) {
// Media3 exposes the parsed index via ChunkExtractor.getChunkIndex() now.
ChunkIndex chunkIndex = holder.extractorWrapper.getChunkIndex();
if (chunkIndex != null) {
representationHolders[trackIndex] =
holder.copyWithNewSegmentIndex(
new SabrWrappingSegmentIndex(
chunkIndex,
holder.representation.presentationTimeOffsetUs));
}
}
}
}
if (playerTrackEmsgHandler != null) {
playerTrackEmsgHandler.onChunkLoadCompleted(chunk);
}
}
@Override
public boolean onChunkLoadError(
Chunk chunk,
boolean cancelable,
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo,
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
if (!cancelable) return false;
// Manifest-driven refresh (same behavior you had before).
if (playerTrackEmsgHandler != null && playerTrackEmsgHandler.maybeRefreshManifestOnLoadingError(chunk)) {
return true; // cancel & re-resolve next chunk
}
// Workaround for a missing last segment on VOD (404) unchanged logic, updated signature.
if (!manifest.dynamic
&& chunk instanceof MediaChunk
&& loadErrorInfo.exception instanceof InvalidResponseCodeException
&& ((InvalidResponseCodeException) loadErrorInfo.exception).responseCode == 404) {
RepresentationHolder holder = representationHolders[trackSelection.indexOf(chunk.trackFormat)];
int count = holder.getSegmentCount();
if (count != SabrSegmentIndex.INDEX_UNBOUNDED && count != 0) {
long lastAvailable = holder.getFirstSegmentNum() + count - 1;
if (((MediaChunk) chunk).getNextChunkIndex() > lastAvailable) {
missingLastSegment = true;
return true; // cancel; well end the period gracefully
}
}
}
// Modern fallback track exclusion using LoadErrorHandlingPolicy
int excluded = 0;
long nowMs = SystemClock.elapsedRealtime();
for (int i = 0; i < trackSelection.length(); i++) {
if (trackSelection.isTrackExcluded(i, nowMs)) excluded++;
}
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions options =
new androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions(
/* numberOfLocations= */ 1, /* numberOfExcludedLocations= */ 0,
/* numberOfTracks= */ trackSelection.length(), /* numberOfExcludedTracks= */ excluded);
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackSelection sel =
loadErrorHandlingPolicy.getFallbackSelectionFor(options, loadErrorInfo);
if (sel != null
&& sel.type == androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
int trackIdx = trackSelection.indexOf(chunk.trackFormat);
return trackSelection.excludeTrack(trackIdx, sel.exclusionDurationMs);
}
return false;
}
private ArrayList<Representation> getRepresentations() {
List<AdaptationSet> manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets;
ArrayList<Representation> representations = new ArrayList<>();
for (int adaptationSetIndex : adaptationSetIndices) {
representations.addAll(manifestAdaptationSets.get(adaptationSetIndex).representations);
}
return representations;
}
private long resolveTimeToLiveEdgeUs(long playbackPositionUs) {
boolean resolveTimeToLiveEdgePossible = manifest.dynamic && liveEdgeTimeUs != C.TIME_UNSET;
return resolveTimeToLiveEdgePossible ? liveEdgeTimeUs - playbackPositionUs : C.TIME_UNSET;
}
private long getNowUnixTimeUs() {
if (elapsedRealtimeOffsetMs != 0) {
return (SystemClock.elapsedRealtime() + elapsedRealtimeOffsetMs) * 1000;
} else {
return System.currentTimeMillis() * 1000;
}
}
@Override
public void release() {
// Forward-looking: free extractor resources if needed.
for (RepresentationHolder h : representationHolders) {
if (h != null && h.extractorWrapper != null) {
h.extractorWrapper.release();
}
}
}
private long getSegmentNum(
RepresentationHolder representationHolder,
@Nullable MediaChunk previousChunk,
long loadPositionUs,
long firstAvailableSegmentNum,
long lastAvailableSegmentNum) {
return previousChunk != null
? previousChunk.getNextChunkIndex()
: Util.constrainValue(representationHolder.getSegmentNum(loadPositionUs), firstAvailableSegmentNum, lastAvailableSegmentNum);
}
private void updateLiveEdgeTimeUs(
RepresentationHolder representationHolder, long lastAvailableSegmentNum) {
liveEdgeTimeUs = manifest.dynamic
? representationHolder.getSegmentEndTimeUs(lastAvailableSegmentNum) : C.TIME_UNSET;
}
protected Chunk newInitializationChunk(
RepresentationHolder representationHolder,
DataSource dataSource,
Format trackFormat,
int trackSelectionReason,
Object trackSelectionData,
RangedUri initializationUri,
RangedUri indexUri) {
RangedUri requestUri;
String baseUrl = representationHolder.representation.baseUrl;
if (initializationUri != null) {
// It's common for initialization and index data to be stored adjacently. Attempt to merge
// the two requests together to request both at once.
requestUri = initializationUri.attemptMerge(indexUri, baseUrl);
if (requestUri == null) {
requestUri = initializationUri;
}
} else {
requestUri = indexUri;
}
// TODO: first protobuf request (before the video start off)
DataSpec dataSpec = new DataSpec(requestUri.resolveUri(baseUrl), requestUri.start,
requestUri.length, representationHolder.representation.getCacheKey());
return new InitializationChunk(dataSource, dataSpec, trackFormat,
trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper);
}
protected Chunk newMediaChunk(
RepresentationHolder representationHolder,
DataSource dataSource,
int trackType,
Format trackFormat,
int trackSelectionReason,
Object trackSelectionData,
long firstSegmentNum,
int maxSegmentCount,
long seekTimeUs) {
Representation representation = representationHolder.representation;
long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum);
RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum);
String baseUrl = representation.baseUrl;
if (representationHolder.extractorWrapper == null) {
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum);
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
segmentUri.start, segmentUri.length, representation.getCacheKey());
return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason,
trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackType, trackFormat);
} else {
int segmentCount = 1;
for (int i = 1; i < maxSegmentCount; i++) {
RangedUri nextSegmentUri = representationHolder.getSegmentUrl(firstSegmentNum + i);
RangedUri mergedSegmentUri = segmentUri.attemptMerge(nextSegmentUri, baseUrl);
if (mergedSegmentUri == null) {
// Unable to merge segment fetches because the URIs do not merge.
break;
}
segmentUri = mergedSegmentUri;
segmentCount++;
}
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1);
long periodDurationUs = representationHolder.periodDurationUs;
long clippedEndTimeUs =
periodDurationUs != C.TIME_UNSET && periodDurationUs <= endTimeUs
? periodDurationUs
: C.TIME_UNSET;
// TODO: next protobuf requests (during the playback)
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
segmentUri.start, segmentUri.length, representation.getCacheKey());
long sampleOffsetUs = -representation.presentationTimeOffsetUs;
return new ContainerMediaChunk(
dataSource,
dataSpec,
trackFormat,
trackSelectionReason,
trackSelectionData,
startTimeUs,
endTimeUs,
seekTimeUs,
clippedEndTimeUs,
firstSegmentNum,
segmentCount,
sampleOffsetUs,
representationHolder.extractorWrapper);
}
}
/** {@link MediaChunkIterator} wrapping a {@link RepresentationHolder}. */
protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator {
private final RepresentationHolder representationHolder;
/**
* Creates iterator.
*
* @param representation The {@link RepresentationHolder} to wrap.
* @param firstAvailableSegmentNum The number of the first available segment.
* @param lastAvailableSegmentNum The number of the last available segment.
*/
public RepresentationSegmentIterator(
RepresentationHolder representation,
long firstAvailableSegmentNum,
long lastAvailableSegmentNum) {
super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum);
this.representationHolder = representation;
}
@Override
public DataSpec getDataSpec() {
checkInBounds();
Representation representation = representationHolder.representation;
RangedUri segmentUri = representationHolder.getSegmentUrl(getCurrentIndex());
Uri resolvedUri = segmentUri.resolveUri(representation.baseUrl);
String cacheKey = representation.getCacheKey();
return new DataSpec(resolvedUri, segmentUri.start, segmentUri.length, cacheKey);
}
@Override
public long getChunkStartTimeUs() {
checkInBounds();
return representationHolder.getSegmentStartTimeUs(getCurrentIndex());
}
@Override
public long getChunkEndTimeUs() {
checkInBounds();
return representationHolder.getSegmentEndTimeUs(getCurrentIndex());
}
}
/** Holds information about a snapshot of a single {@link Representation}. */
protected static final class RepresentationHolder {
/* package */ final @Nullable ChunkExtractor extractorWrapper;
public final Representation representation;
public final @Nullable SabrSegmentIndex segmentIndex;
private final long periodDurationUs;
private final long segmentNumShift;
/* package */ RepresentationHolder(
long periodDurationUs,
int trackType,
Representation representation,
boolean enableEventMessageTrack,
List<Format> closedCaptionFormats,
TrackOutput playerEmsgTrackOutput) {
this(
periodDurationUs,
representation,
createExtractorWrapper(
trackType,
representation,
enableEventMessageTrack,
closedCaptionFormats,
playerEmsgTrackOutput),
/* segmentNumShift= */ 0,
representation.getIndex());
}
private RepresentationHolder(
long periodDurationUs,
Representation representation,
@Nullable ChunkExtractor extractorWrapper,
long segmentNumShift,
@Nullable SabrSegmentIndex segmentIndex) {
this.periodDurationUs = periodDurationUs;
this.representation = representation;
this.segmentNumShift = segmentNumShift;
this.extractorWrapper = extractorWrapper;
this.segmentIndex = segmentIndex;
}
@CheckResult
/* package */ RepresentationHolder copyWithNewRepresentation(
long newPeriodDurationUs, Representation newRepresentation)
throws BehindLiveWindowException {
SabrSegmentIndex oldIndex = representation.getIndex();
SabrSegmentIndex newIndex = newRepresentation.getIndex();
if (oldIndex == null) {
// Segment numbers cannot shift if the index isn't defined by the manifest.
return new RepresentationHolder(
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, oldIndex);
}
if (!oldIndex.isExplicit()) {
// Segment numbers cannot shift if the index isn't explicit.
return new RepresentationHolder(
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
}
int oldIndexSegmentCount = oldIndex.getSegmentCount(newPeriodDurationUs);
if (oldIndexSegmentCount == 0) {
// Segment numbers cannot shift if the old index was empty.
return new RepresentationHolder(
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
}
long oldIndexFirstSegmentNum = oldIndex.getFirstSegmentNum();
long oldIndexStartTimeUs = oldIndex.getTimeUs(oldIndexFirstSegmentNum);
long oldIndexLastSegmentNum = oldIndexFirstSegmentNum + oldIndexSegmentCount - 1;
long oldIndexEndTimeUs =
oldIndex.getTimeUs(oldIndexLastSegmentNum)
+ oldIndex.getDurationUs(oldIndexLastSegmentNum, newPeriodDurationUs);
long newIndexFirstSegmentNum = newIndex.getFirstSegmentNum();
long newIndexStartTimeUs = newIndex.getTimeUs(newIndexFirstSegmentNum);
long newSegmentNumShift = segmentNumShift;
if (oldIndexEndTimeUs == newIndexStartTimeUs) {
// The new index continues where the old one ended, with no overlap.
newSegmentNumShift += oldIndexLastSegmentNum + 1 - newIndexFirstSegmentNum;
} else if (oldIndexEndTimeUs < newIndexStartTimeUs) {
// There's a gap between the old index and the new one which means we've slipped behind the
// live window and can't proceed.
throw new BehindLiveWindowException();
} else if (newIndexStartTimeUs < oldIndexStartTimeUs) {
// The new index overlaps with (but does not have a start position contained within) the old
// index. This can only happen if extra segments have been added to the start of the index.
newSegmentNumShift -=
newIndex.getSegmentNum(oldIndexStartTimeUs, newPeriodDurationUs)
- oldIndexFirstSegmentNum;
} else {
// The new index overlaps with (and has a start position contained within) the old index.
newSegmentNumShift +=
oldIndex.getSegmentNum(newIndexStartTimeUs, newPeriodDurationUs)
- newIndexFirstSegmentNum;
}
return new RepresentationHolder(
newPeriodDurationUs, newRepresentation, extractorWrapper, newSegmentNumShift, newIndex);
}
@CheckResult
/* package */ RepresentationHolder copyWithNewSegmentIndex(SabrSegmentIndex segmentIndex) {
return new RepresentationHolder(
periodDurationUs, representation, extractorWrapper, segmentNumShift, segmentIndex);
}
public long getFirstSegmentNum() {
return segmentIndex.getFirstSegmentNum() + segmentNumShift;
}
public int getSegmentCount() {
return segmentIndex.getSegmentCount(periodDurationUs);
}
public long getSegmentStartTimeUs(long segmentNum) {
return segmentIndex.getTimeUs(segmentNum - segmentNumShift);
}
public long getSegmentEndTimeUs(long segmentNum) {
return getSegmentStartTimeUs(segmentNum)
+ segmentIndex.getDurationUs(segmentNum - segmentNumShift, periodDurationUs);
}
public long getSegmentNum(long positionUs) {
return segmentIndex.getSegmentNum(positionUs, periodDurationUs) + segmentNumShift;
}
public RangedUri getSegmentUrl(long segmentNum) {
return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift);
}
public long getFirstAvailableSegmentNum(
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
if (getSegmentCount() == SabrSegmentIndex.INDEX_UNBOUNDED
&& manifest.timeShiftBufferDepthMs != C.TIME_UNSET) {
// The index is itself unbounded. We need to use the current time to calculate the range of
// available segments.
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs);
return Math.max(
getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs));
}
return getFirstSegmentNum();
}
public long getLastAvailableSegmentNum(
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
int availableSegmentCount = getSegmentCount();
if (availableSegmentCount == SabrSegmentIndex.INDEX_UNBOUNDED) {
// The index is itself unbounded. We need to use the current time to calculate the range of
// available segments.
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
// getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet, so subtract one to get
// the index of the last completed segment.
return getSegmentNum(liveEdgeTimeInPeriodUs) - 1;
}
return getFirstSegmentNum() + availableSegmentCount - 1;
}
private static boolean mimeTypeIsWebm(String mimeType) {
return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM)
|| mimeType.startsWith(MimeTypes.APPLICATION_WEBM);
}
private static boolean mimeTypeIsRawText(String mimeType) {
return MimeTypes.isText(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType);
}
private static @Nullable ChunkExtractor createExtractorWrapper(
int trackType,
Representation representation,
boolean enableEventMessageTrack,
List<Format> closedCaptionFormats,
TrackOutput playerEmsgTrackOutput) {
String containerMimeType = representation.format.containerMimeType;
if (mimeTypeIsRawText(containerMimeType)) {
return null;
}
Extractor extractor = new SabrExtractor(trackType, representation.format);
return new BundledChunkExtractor(extractor, trackType, representation.format);
}
}
}
@@ -0,0 +1,151 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.futo.platformplayer.sabr;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.emsg.EventMessageEncoder;
import androidx.media3.exoplayer.source.SampleStream;
import com.futo.platformplayer.sabr.manifest.EventStream;
import androidx.media3.common.util.Util;
import java.io.IOException;
/**
* A {@link SampleStream} consisting of serialized {@link EventMessage}s read from an
* {@link EventStream}.
*/
@UnstableApi
/* package */ final class EventSampleStream implements SampleStream {
private final Format upstreamFormat;
private final EventMessageEncoder eventMessageEncoder;
private long[] eventTimesUs;
private boolean eventStreamAppendable;
private EventStream eventStream;
private boolean isFormatSentDownstream;
private int currentIndex;
private long pendingSeekPositionUs;
public EventSampleStream(
EventStream eventStream, Format upstreamFormat, boolean eventStreamAppendable) {
this.upstreamFormat = upstreamFormat;
this.eventStream = eventStream;
eventMessageEncoder = new EventMessageEncoder();
pendingSeekPositionUs = C.TIME_UNSET;
eventTimesUs = eventStream.presentationTimesUs;
updateEventStream(eventStream, eventStreamAppendable);
}
public String eventStreamId() {
return eventStream.id();
}
public void updateEventStream(EventStream eventStream, boolean eventStreamAppendable) {
long lastReadPositionUs = currentIndex == 0 ? C.TIME_UNSET : eventTimesUs[currentIndex - 1];
this.eventStreamAppendable = eventStreamAppendable;
this.eventStream = eventStream;
this.eventTimesUs = eventStream.presentationTimesUs;
if (pendingSeekPositionUs != C.TIME_UNSET) {
seekToUs(pendingSeekPositionUs);
} else if (lastReadPositionUs != C.TIME_UNSET) {
currentIndex =
Util.binarySearchCeil(
eventTimesUs, lastReadPositionUs, /* inclusive= */ false, /* stayInBounds= */ false);
}
}
/**
* Seeks to the specified position in microseconds.
*
* @param positionUs The seek position in microseconds.
*/
public void seekToUs(long positionUs) {
currentIndex =
Util.binarySearchCeil(
eventTimesUs, positionUs, /* inclusive= */ true, /* stayInBounds= */ false);
boolean isPendingSeek = eventStreamAppendable && currentIndex == eventTimesUs.length;
pendingSeekPositionUs = isPendingSeek ? positionUs : C.TIME_UNSET;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void maybeThrowError() throws IOException {
// Do nothing.
}
@Override
public int readData(
FormatHolder formatHolder,
DecoderInputBuffer buffer,
@SampleStream.ReadFlags int readFlags) {
final boolean requireFormat = (readFlags & SampleStream.FLAG_REQUIRE_FORMAT) != 0;
final boolean omitSampleData = (readFlags & SampleStream.FLAG_OMIT_SAMPLE_DATA) != 0;
if (requireFormat || !isFormatSentDownstream) {
formatHolder.format = upstreamFormat;
isFormatSentDownstream = true;
return C.RESULT_FORMAT_READ;
}
if (currentIndex == eventTimesUs.length) {
if (!eventStreamAppendable) {
buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
return C.RESULT_BUFFER_READ;
} else {
return C.RESULT_NOTHING_READ;
}
}
final int sampleIndex = currentIndex++;
final byte[] serializedEvent = eventMessageEncoder.encode(eventStream.events[sampleIndex]);
if (serializedEvent == null) {
return C.RESULT_NOTHING_READ;
}
buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME);
buffer.timeUs = eventTimesUs[sampleIndex];
if (!omitSampleData) {
buffer.ensureSpaceForWrite(serializedEvent.length);
buffer.data.put(serializedEvent);
}
return C.RESULT_BUFFER_READ;
}
@Override
public int skipData(long positionUs) {
int newIndex =
Math.max(currentIndex, Util.binarySearchCeil(eventTimesUs, positionUs, true, false));
int skipped = newIndex - currentIndex;
currentIndex = newIndex;
return skipped;
}
}
@@ -0,0 +1,109 @@
package com.futo.platformplayer.sabr;
import java.util.Arrays;
import java.util.List;
public final class ITagUtils {
public final static String AUDIO_68K_WEBM = "249";
public final static String AUDIO_89K_WEBM = "250";
public final static String AUDIO_133K_WEBM = "171";
public final static String AUDIO_156K_WEBM = "251";
public final static String AUDIO_48K_AAC = "139";
public final static String AUDIO_128K_AAC = "140";
public final static String VIDEO_144P_WEBM = "278";
public final static String VIDEO_144P_AVC = "160";
public final static String VIDEO_240P_WEBM = "242";
public final static String VIDEO_240P_AVC = "133";
public final static String VIDEO_360P_WEBM = "243";
public final static String VIDEO_360P_AVC = "134";
public final static String VIDEO_480P_WEBM = "244";
public final static String VIDEO_480P_AVC = "135";
public final static String VIDEO_720P_WEBM = "247";
public final static String VIDEO_720P_WEBM_60FPS_HDR = "334";
public final static String VIDEO_720P_AVC = "136";
public final static String VIDEO_720P_AVC_60FPS = "298";
public final static String VIDEO_1080P_WEBM = "248";
public final static String VIDEO_1080P_WEBM_60FPS_HDR = "335";
public final static String VIDEO_1080P_AVC = "137";
public final static String VIDEO_1080P_AVC_60FPS = "299";
public final static String VIDEO_1440P_WEBM = "271";
public final static String VIDEO_1440P_WEBM_60FPS_HDR = "336";
public final static String VIDEO_1440P_WEBM_60FPS = "308";
public final static String VIDEO_1440P_AVC = "264";
public final static String VIDEO_2160P_WEBM = "313";
public final static String VIDEO_2160P_WEBM_60FPS_HDR = "337";
public final static String VIDEO_2160P_WEBM_60FPS = "315";
public final static String VIDEO_2160P_AVC = "266";
public final static String VIDEO_2160P_AVC_HQ = "138";
public final static String MUXED_360P_WEBM = "43";
public final static String MUXED_360P_AVC = "18";
public final static String MUXED_720P_AVC = "22";
private final static List<String> sOrderedITagsAVC = Arrays.asList(
MUXED_360P_AVC, MUXED_720P_AVC,
AUDIO_48K_AAC, AUDIO_128K_AAC,
VIDEO_144P_AVC, VIDEO_240P_AVC,
VIDEO_360P_AVC, VIDEO_480P_AVC, VIDEO_720P_AVC, VIDEO_720P_AVC_60FPS,
VIDEO_1080P_AVC, VIDEO_1080P_AVC_60FPS, VIDEO_1440P_AVC, VIDEO_2160P_AVC, VIDEO_2160P_AVC_HQ);
private final static List<String> sOrderedITagsWEBM = Arrays.asList(
MUXED_360P_WEBM,
AUDIO_68K_WEBM, AUDIO_89K_WEBM, AUDIO_133K_WEBM, AUDIO_156K_WEBM,
VIDEO_144P_WEBM, VIDEO_240P_WEBM,
VIDEO_360P_WEBM, VIDEO_480P_WEBM, VIDEO_720P_WEBM, VIDEO_720P_WEBM_60FPS_HDR,
VIDEO_1080P_WEBM, VIDEO_1080P_WEBM_60FPS_HDR, VIDEO_1440P_WEBM, VIDEO_1440P_WEBM_60FPS_HDR, VIDEO_1440P_WEBM_60FPS,
VIDEO_2160P_WEBM, VIDEO_2160P_WEBM_60FPS_HDR, VIDEO_2160P_WEBM_60FPS);
private final static List<List<String>> sITagsContainer = Arrays.asList(sOrderedITagsAVC, sOrderedITagsWEBM);
public static final String AVC = "AVC";
public static final String WEBM = "VP9";
public static int compare(String leftITag, String rightITag) {
for (List<String> iTags : sITagsContainer) {
int left = iTags.indexOf(leftITag);
int right = iTags.indexOf(rightITag);
if (left != -1 && right != -1) {
return left - right;
}
}
// TODO: we can't be here
return 99;
}
public static boolean belongsToType(String type, String iTag) {
String realType = getRealType(iTag);
return type.equals(realType);
}
public static boolean belongsToType(String type, int iTag) {
String realType = getRealType(String.valueOf(iTag));
return type.equals(realType);
}
private static String getRealType(String iTag) {
if (sOrderedITagsAVC.contains(iTag)) {
return AVC;
}
return WEBM;
}
public static String getAudioRateByTag(String iTag) {
switch (iTag) {
case AUDIO_128K_AAC:
return "44100";
case AUDIO_48K_AAC:
return "22050";
case AUDIO_156K_WEBM:
return "48000";
case AUDIO_133K_WEBM:
return "44100";
case AUDIO_89K_WEBM:
return "48000";
case AUDIO_68K_WEBM:
return "48000";
}
return "44100";
}
}
@@ -0,0 +1,45 @@
package com.futo.platformplayer.sabr;
import java.util.List;
public interface MediaFormat extends Comparable<MediaFormat> {
int FORMAT_TYPE_DASH = 0;
int FORMAT_TYPE_REGULAR = 1;
int FORMAT_TYPE_SABR = 2;
// Common
int getFormatType();
String getUrl();
String getMimeType();
String getITag();
boolean isDrc();
// DASH
String getClen();
String getBitrate();
String getProjectionType();
String getXtags();
int getWidth();
int getHeight();
String getIndex();
String getInit();
String getFps();
String getLmt();
String getQualityLabel();
String getFormat();
boolean isOtf();
String getOtfInitUrl();
String getOtfTemplateUrl();
String getLanguage();
// DASH LIVE
String getTargetDurationSec();
String getLastModified();
String getMaxDvrDurationSec();
// Other/Regular
String getQuality();
String getSignature();
String getAudioSamplingRate();
String getSourceUrl();
List<String> getSegmentUrlList();
List<String> getGlobalSegmentList();
}

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