Compare commits

...

68 Commits

Author SHA1 Message Date
Kelvin 0f60d4737e Plugin update appToast, Refs 2024-01-03 19:47:38 +01:00
Kelvin 0dc33e1f2b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-03 17:59:11 +01:00
Kelvin d86a997a88 appToast system, VideoToOpen changed 2024-01-03 17:59:01 +01:00
Koen 34d4d92289 Casting stability fixes to ChromeCast connection thread. 2024-01-03 11:24:55 +01:00
Koen 4cb1bf268f Updated YouTube submodule. 2024-01-03 11:09:57 +01:00
Kelvin 8488706ff9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 20:14:36 +01:00
Kelvin a348bb2662 Fix crash download livestream, refs 2024-01-02 20:14:28 +01:00
Koen 60a17b3c67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 19:48:51 +01:00
Koen 386c58d4ad Added timeouts to casting flow. 2024-01-02 19:48:43 +01:00
Kelvin 356ba01dc1 Fix default comment section 2024-01-02 16:28:08 +01:00
Kelvin ed2aa848da Fix clear playback notification, Update V8 Library, Close modified requests after usage 2024-01-02 15:55:29 +01:00
Kelvin c5dd90048f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 14:36:46 +01:00
Kelvin ab04f334dc Download cleanup after cancel/failure and on startup, auto-select subtitles if downloaded, refs 2024-01-02 14:36:41 +01:00
Koen 0d44f8a416 Fixed issue where some videos would not play in Odysee. 2024-01-02 13:36:43 +01:00
Koen d01a1545e2 Allow large heap to fix Rumble failing to load large videos. 2024-01-02 13:35:48 +01:00
Kelvin e599729ba1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 15:21:52 +01:00
Kelvin 3ac043740e Fix RequestModifier not being applied, and add default option to add pre-existing headers 2023-12-27 15:21:43 +01:00
Koen 89603d0ff3 changed typeface when update is available. 2023-12-27 14:31:26 +01:00
Koen 05b6cd7c97 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 11:11:36 +01:00
Koen ea5aad0631 Implemented check for updates. 2023-12-27 11:11:29 +01:00
Kelvin 96e034b9bf Fix plugin.d.ts and update 2023-12-22 19:29:38 +01:00
Kelvin 6141c36855 Refs 2023-12-21 20:04:53 +01:00
Kelvin 4084ab3ed0 refs 2023-12-21 20:01:51 +01:00
Kelvin 34e733823a Refs 2023-12-21 19:27:13 +01:00
Kelvin f1d01642cd Refs, ui fixes 2023-12-21 19:24:30 +01:00
Kelvin d5551d7118 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:24:18 +01:00
Kelvin d079a1e8e4 Add missing channel name setter 2023-12-21 17:24:11 +01:00
Koen c06c00ee9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:19:17 +01:00
Koen 1d8eababc2 Minor casting dialog fixes. 2023-12-21 17:19:05 +01:00
Kelvin 75cf1ffbdd Offline playback fix with remote sources 2023-12-21 17:13:43 +01:00
Kelvin 5499706a9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 16:14:01 +01:00
Kelvin ba57e32920 Minor queue styling, refs 2023-12-21 16:13:53 +01:00
Koen df96c5b51c Fixed live video previews in preview layout. Fixed time bar spacing at bottom. 2023-12-21 16:07:03 +01:00
Koen 75f81d20db Fixed buttons in subscription groups and made select button click only work when there are things selected. 2023-12-21 13:10:38 +01:00
Koen 3fc92e4065 Fixed rounding of subscription groups. 2023-12-21 12:56:35 +01:00
Koen 8ffd5f411f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:21:34 +01:00
Koen 918161a299 Fixed margins for subscription groups and menu bar icons now show filled variant if existing. 2023-12-21 11:20:53 +01:00
Kelvin 9f50f72eaa Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:12:45 +01:00
Kelvin 2f66f124aa Toast on cancel creation, default subgroup creator image, fix subgroup cache load 2023-12-21 11:12:34 +01:00
Koen 9a11717cf4 Fixed Rumble channel contents having empty author. Changed TutorialVideo so author is not clickable. 2023-12-21 10:20:52 +01:00
Kelvin 0d80424799 Optimized selection creator overlay, global subscription progress listener 2023-12-20 21:54:39 +01:00
Kelvin ed9a65b2f0 Filter cache results by enabled clients 2023-12-20 18:35:13 +01:00
Kelvin 8a53297be2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 18:08:52 +01:00
Kelvin 20862a27c8 Search creator overlay, Fix crash Add topbar 2023-12-20 18:08:40 +01:00
Koen 95785e6c78 Added something more similar to Jdenticons. 2023-12-20 17:35:57 +01:00
Koen e88c649578 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 17:09:19 +01:00
Koen 09f91e64fb Fixed Kick subscription imports. 2023-12-20 17:04:10 +01:00
Koen b8923e59a1 Fixed bottom bar new tabs not showing up for people who changed tabs. 2023-12-20 17:03:48 +01:00
Kelvin e722c0ce9a Explore subscription groups button 2023-12-20 17:02:40 +01:00
Kelvin 56248bf4b0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 12:36:12 +01:00
Kelvin 5af4787c45 Fix nested overlays in more buttons 2023-12-20 12:35:40 +01:00
Koen 0990247322 Fixed Rumble channel content fetching, channel image and show comments when sign in is not required. 2023-12-20 12:06:21 +01:00
Koen 0154525578 Revert "Fixed download button overlay not working in more button."
This reverts commit 1dc6eee242.
2023-12-20 12:02:35 +01:00
Koen 1dc6eee242 Fixed download button overlay not working in more button. 2023-12-20 11:51:28 +01:00
Koen c63a63cb33 Fixed Gesture control distances for portrait full screen. 2023-12-20 11:06:18 +01:00
Koen c1967556ac Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 10:56:28 +01:00
Koen 309a57f5a1 Added icon based on the system key when a polycentric user has not set a profile picture. Made managing Polycentric profile most robust. Fixed an issue where latest events were not always being shown in relation to Polycentric profiles. Added copyable (on long press) system key in Polycentric profile activity. Added tutorial videos tab and flow. 2023-12-20 10:56:16 +01:00
Kelvin ee0bc96e53 Support for initial skip chapters 2023-12-20 00:28:21 +01:00
Kelvin a4422fdd56 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 23:08:48 +01:00
Kelvin b7c4047f1d Progress bars subscription groups, Various notification permission requests, improved notification experience, Settings activity open to specific settings, refs 2023-12-19 23:08:38 +01:00
Koen 65174ffc97 Show connected controls as disabled when still connecting. 2023-12-19 11:46:40 +01:00
Koen eac3e37af5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 11:38:32 +01:00
Koen 0d5ad90ff9 Fixed duration format. Added tutorial fragment. Added dialog that asks you if you want to see the tutorials on the first app boot. Made casting when clicking start now open connected dialog. Fixed crashes related to sliders in casting connected control. Fixed history tab title. Removed old FCast encryption. 2023-12-19 11:38:22 +01:00
Kelvin f42b14e95a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 23:37:27 +01:00
Kelvin b8acd0b5b2 Subscriptions fetching support for subgroups 2023-12-18 23:37:10 +01:00
Koen ef72561768 Added support for chapter skip in casting. 2023-12-18 16:28:35 +01:00
Koen d63627bd61 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 15:48:17 +01:00
Koen 422cceb225 Added support for FCast encrypted connection upgrade and added support for ensuring threads are restarted onResume for FCast and ChromeCAst. 2023-12-18 15:48:05 +01:00
145 changed files with 3750 additions and 1274 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation("com.caoccao.javet:javet-android:2.2.1")
implementation("com.caoccao.javet:javet-android:3.0.2")
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.2.0'
@@ -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)
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:targetApi="31">
tools:targetApi="31"
android:largeHeap="true">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority"
+120 -46
View File
@@ -1,13 +1,37 @@
declare class ScriptException extends Error {
//If only one parameter is provided, acts as msg
constructor(type: string, msg: string);
}
declare class TimeoutException extends ScriptException {
declare class LoginRequiredException extends ScriptException {
constructor(msg: string);
}
//Alias
declare class ScriptLoginRequiredException extends ScriptException {
constructor(msg: string);
}
declare class CaptchaRequiredException extends ScriptException {
constructor(url: string, body: string);
}
declare class CriticalException extends ScriptException {
constructor(msg: string);
}
declare class UnavailableException extends ScriptException {
constructor(msg: string);
}
declare class AgeException extends ScriptException {
constructor(msg: string);
}
declare class TimeoutException extends ScriptException {
constructor(msg: string);
}
declare class ScriptImplementationException extends ScriptException {
constructor(msg: string);
}
@@ -38,16 +62,23 @@ declare class FilterCapability {
declare class PlatformAuthorLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?);
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
}
declare class PlatformAuthorMembershipLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
}
declare interface PlatformContentDef {
id: PlatformID,
name: string,
thumbnails: Thumbnails,
author: PlatformAuthorLink,
datetime: integer,
url: string
}
declare interface PlatformContent {}
declare interface PlatformNestedMediaContentDef extends PlatformContentDef {
contentUrl: string,
contentName: string?,
@@ -59,16 +90,26 @@ declare class PlatformNestedMediaContent {
constructor(obj: PlatformNestedMediaContentDef);
}
declare interface PlatformLockedContentDef extends PlatformContentDef {
contentName: string?,
contentThumbnails: Thumbnails?,
unlockUrl: string,
lockDescription: string?,
}
declare class PlatformLockedContent {
constructor(obj: PlatformLockedContentDef);
}
declare interface PlatformVideoDef extends PlatformContentDef {
thumbnails: Thumbnails,
author: PlatformAuthorLink,
duration: int,
viewCount: long,
isLive: boolean
isLive: boolean,
shareUrl: string?
}
declare interface PlatformContent {}
declare class PlatformVideo implements PlatformContent {
constructor(obj: PlatformVideoDef);
}
@@ -77,14 +118,15 @@ declare class PlatformVideo implements PlatformContent {
declare interface PlatformVideoDetailsDef extends PlatformVideoDef {
description: string,
video: VideoSourceDescriptor,
live: SubtitleSource[],
rating: IRating
live: IVideoSource,
rating: IRating,
subtitles: SubtitleSource[]
}
declare class PlatformVideoDetails extends PlatformVideo {
constructor(obj: PlatformVideoDetailsDef);
}
declare class PlatformPostDef extends PlatformContentDef {
declare interface PlatformPostDef extends PlatformContentDef {
thumbnails: string[],
images: string[],
description: string
@@ -93,7 +135,7 @@ declare class PlatformPost extends PlatformContent {
constructor(obj: PlatformPostDef)
}
declare class PlatformPostDetailsDef extends PlatformPostDef {
declare interface PlatformPostDetailsDef extends PlatformPostDef {
rating: IRating,
textType: int,
content: String
@@ -110,8 +152,8 @@ declare interface MuxVideoSourceDescriptorDef {
isUnMuxed: boolean,
videoSources: VideoSource[]
}
declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor {
constructor(obj: VideoSourceDescriptorDef);
declare class VideoSourceDescriptor implements IVideoSourceDescriptor {
constructor(videoSourcesOrObj: VideoSource[]);
}
declare interface UnMuxVideoSourceDescriptorDef {
@@ -129,7 +171,7 @@ declare interface IVideoSource {
declare interface IAudioSource {
}
interface VideoUrlSourceDef implements IVideoSource {
declare interface VideoUrlSourceDef implements IVideoSource {
width: integer,
height: integer,
container: string,
@@ -139,22 +181,22 @@ interface VideoUrlSourceDef implements IVideoSource {
duration: integer,
url: string
}
class VideoUrlSource {
declare class VideoUrlSource {
constructor(obj: VideoUrlSourceDef);
getRequestModifier(): RequestModifier?;
}
interface VideoUrlRangeSourceDef extends VideoUrlSource {
declare interface VideoUrlRangeSourceDef extends VideoUrlSource {
itagId: integer,
initStart: integer,
initEnd: integer,
indexStart: integer,
indexEnd: integer,
}
class VideoUrlRangeSource extends VideoUrlSource {
declare class VideoUrlRangeSource extends VideoUrlSource {
constructor(obj: YTVideoSourceDef);
}
interface AudioUrlSourceDef {
declare interface AudioUrlSourceDef {
name: string,
bitrate: integer,
container: string,
@@ -163,24 +205,12 @@ interface AudioUrlSourceDef {
url: string,
language: string
}
class AudioUrlSource implements IAudioSource {
declare class AudioUrlSource implements IAudioSource {
constructor(obj: AudioUrlSourceDef);
getRequestModifier(): RequestModifier?;
}
interface IRequest {
url: string,
headers: Map<string, string>
}
interface IRequestModifierDef {
allowByteSkip: boolean
}
class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
interface AudioUrlRangeSourceDef extends AudioUrlSource {
declare interface AudioUrlRangeSourceDef extends AudioUrlSource {
itagId: integer,
initStart: integer,
initEnd: integer,
@@ -188,28 +218,44 @@ interface AudioUrlRangeSourceDef extends AudioUrlSource {
indexEnd: integer,
audioChannels: integer
}
class AudioUrlRangeSource extends AudioUrlSource {
declare class AudioUrlRangeSource extends AudioUrlSource {
constructor(obj: AudioUrlRangeSourceDef);
}
interface HLSSourceDef {
declare interface HLSSourceDef {
name: string,
duration: integer,
url: string
url: string,
priority: boolean?,
language: string?
}
class HLSSource implements IVideoSource {
declare class HLSSource implements IVideoSource {
constructor(obj: HLSSourceDef);
}
interface DashSourceDef {
declare interface DashSourceDef {
name: string,
duration: integer,
url: string
url: string,
language: string?
}
class DashSource implements IVideoSource {
declare class DashSource implements IVideoSource {
constructor(obj: DashSourceDef)
}
declare interface IRequest {
url: string,
headers: Map<string, string>
}
declare interface IRequestModifierDef {
allowByteSkip: boolean
}
declare class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
//Channel
interface PlatformChannelDef {
declare interface PlatformChannelDef {
id: PlatformID,
name: string,
thumbnail: string,
@@ -217,12 +263,29 @@ interface PlatformChannelDef {
subscribers: integer,
description: string,
url: string,
urlAlternatives: string[],
links: Map<string>?
}
class PlatformChannel {
declare class PlatformChannel {
constructor(obj: PlatformChannelDef);
}
//Playlist
declare interface PlatformPlaylistDef implements PlatformContent {
videoCount: integer,
thumbnail: string
}
declare class PlatformPlaylist extends PlatformContent {
constructor(obj: PlatformPlaylistDef);
}
declare interface PlatformPlaylistDetailsDef implements PlatformPlaylistDef {
contents: ContentPager
}
declare class PlatformPlaylistDetails extends PlatformContent {
constructor(obj: PlatformPlaylistDetailsDef);
}
//Ratings
interface IRating {
type: integer
@@ -250,7 +313,11 @@ declare class PlatformComment {
constructor(obj: CommentDef);
}
declare class PlaybackTracker {
constructor(interval: integer);
setProgress(seconds: integer);
}
declare class LiveEventPager {
nextRequest = 4000;
@@ -261,8 +328,8 @@ declare class LiveEventPager {
nextPage(): LiveEventPager; //Could be self
}
class LiveEvent {
type: String
declare class LiveEvent {
constructor(type: integer);
}
declare class LiveEventComment extends LiveEvent {
constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]);
@@ -287,25 +354,31 @@ declare class ContentPager {
constructor(results: PlatformContent[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self
nextPage(): ContentPager?; //Could be self
}
declare class VideoPager {
constructor(results: PlatformVideo[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self
nextPage(): VideoPager?; //Could be self
}
declare class ChannelPager {
constructor(results: PlatformChannel[], hasMore: boolean);
hasMorePagers(): boolean;
nextPage(): ChannelPager; //Could be self
nextPage(): ChannelPager?; //Could be self
}
declare class PlaylistPager {
constructor(results: PlatformPlaylist[], hasMore: boolean);
hasMorePagers(): boolean;
nextPage(): PlaylistPager?;
}
declare class CommentPager {
constructor(results: PlatformComment[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): CommentPager; //Could be self
nextPage(): CommentPager?; //Could be self
}
interface Map<T> {
@@ -341,8 +414,9 @@ interface Source {
getChannelCapabilities(): ResultCapabilities;
isContentDetailsUrl(url: string): boolean;
getContentDetails(url: string): PlatformVideoDetails;
getContentDetails(url: string): PlatformContentDetails;
//Optional
getLiveEvents(url: string): LiveEventPager;
//Optional
+18 -4
View File
@@ -37,7 +37,8 @@ let Type = {
NORMAL: 0,
SKIPPABLE: 5,
SKIP: 6
SKIP: 6,
SKIPONCE: 7
}
};
@@ -77,6 +78,11 @@ class ScriptLoginRequiredException extends ScriptException {
super("ScriptLoginRequiredException", msg);
}
}
class LoginRequiredException extends ScriptException {
constructor(msg) {
super("ScriptLoginRequiredException", msg);
}
}
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
@@ -248,8 +254,8 @@ class PlatformVideoDetails extends PlatformVideo {
this.description = obj.description ?? "";//String
this.video = obj.video ?? {}; //VideoSourceDescriptor
this.dash = obj.dash ?? null; //DashSource
this.hls = obj.hls ?? null; //HLSSource
this.dash = obj.dash ?? null; //DashSource, deprecated
this.hls = obj.hls ?? null; //HLSSource, deprecated
this.live = obj.live ?? null; //VideoSource
this.rating = obj.rating ?? null; //IRating
@@ -320,6 +326,8 @@ class VideoUrlSource {
this.bitrate = obj.bitrate ?? 0;
this.duration = obj.duration ?? 0;
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class VideoUrlRangeSource extends VideoUrlSource {
@@ -345,6 +353,8 @@ class AudioUrlSource {
this.duration = obj.duration ?? 0;
this.url = obj.url;
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class AudioUrlRangeSource extends AudioUrlSource {
@@ -370,6 +380,8 @@ class HLSSource {
this.priority = obj.priority ?? false;
if(obj.language)
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class DashSource {
@@ -381,13 +393,15 @@ class DashSource {
this.url = obj.url;
if(obj.language)
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class RequestModifier {
constructor(obj) {
obj = obj ?? {};
this.allowByteSkip = obj.allowByteSkip;
this.allowByteSkip = obj.allowByteSkip; //Kinda deprecated.. wip
}
}
@@ -232,7 +232,11 @@ fun Long.formatDuration(): String {
val minutes = (this % 3600000) / 60000
val seconds = (this % 60000) / 1000
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
fun String.fixHtmlLinks(): Spanned {
@@ -9,7 +9,6 @@ import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4;
@@ -216,13 +215,15 @@ private fun ByteArray.toInetAddress(): InetAddress {
}
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
val timeout = 5000
if (addresses.isEmpty()) {
return null;
}
if (addresses.size == 1) {
try {
return Socket(addresses[0], port);
return Socket().apply { this.connect(InetSocketAddress(addresses[0], port), timeout) };
} catch (e: Throwable) {
//Ignored.
}
@@ -249,7 +250,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
}
}
socket.connect(InetSocketAddress(address, port));
socket.connect(InetSocketAddress(address, port), timeout);
synchronized(syncObject) {
if (connectedSocket == null) {
@@ -1,11 +1,13 @@
package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import userpackage.Protocol
import kotlin.math.abs
import kotlin.math.min
@@ -47,6 +49,15 @@ fun Protocol.Claim.resolveChannelUrls(): List<String> {
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.STAGING_SERVER)) {
removeServer(PolycentricCache.STAGING_SERVER)
}
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
removeServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
@@ -277,7 +277,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10)
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10, "background_update")
@DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -685,7 +685,9 @@ class Settings : FragmentedStorageFileJson() {
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
StateUpdate.instance.checkForUpdates(it, true);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
}
} else {
SettingsActivity.getActivity()?.let {
@@ -37,6 +37,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -398,13 +399,28 @@ class UIDialogs {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
try {
StateApp.withContext {
Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show();
toast(it, text, long);
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show toast.", e);
}
}
}
fun appToast(text: String, long: Boolean = false) {
appToast(ToastView.Toast(text, long))
}
fun appToastError(text: String, long: Boolean) {
StateApp.withContext {
appToast(ToastView.Toast(text, long, it.getColor(R.color.pastel_red)));
};
}
fun appToast(toast: ToastView.Toast) {
StateApp.withContext {
if(it is MainActivity) {
it.showAppToast(toast);
}
}
}
fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) {
//TODO: Is not actually clickable...
@@ -1,9 +1,14 @@
package com.futo.platformplayer
import android.app.Activity
import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.view.View
import android.view.ViewGroup
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -131,8 +136,29 @@ class UISlideOverlays {
subscription.save();
menu.hide(true);
if(subscription.doNotifications && !originalNotif && Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
if(subscription.doNotifications && !originalNotif) {
val mainContext = StateApp.instance.contextOrNull;
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
if(mainContext is MainActivity) {
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
"You need to set a Background Updating interval for notifications", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
val intent = Intent(mainContext, SettingsActivity::class.java);
intent.putExtra("query", mainContext.getString(R.string.background_update));
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY));
}
return@subscribe;
}
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(container.context, "Android notifications are disabled");
if(mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
}
}
}
};
menu.onCancel.subscribe {
@@ -317,7 +343,7 @@ class UISlideOverlays {
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
) as IVideoUrlSource?;
}
if (audioSources != null) {
@@ -713,7 +739,7 @@ class UISlideOverlays {
}
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), invokeParents: Boolean = true, onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
@@ -721,7 +747,7 @@ class UISlideOverlays {
hidden
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn);
}, true) as View }.toTypedArray(),
}, invokeParents) as View }.toTypedArray(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
val selected = it
@@ -216,8 +216,10 @@ class AddSourceActivity : AppCompatActivity() {
fun install(config: SourcePluginConfig, script: String) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it)
if(it) {
StatePlatform.instance.clearUpdateAvailable(config)
backToSources();
}
}
}
@@ -9,9 +9,11 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
@@ -17,9 +18,12 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
@@ -36,12 +40,12 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.listeners.OrientationManager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
@@ -51,6 +55,7 @@ import java.io.PrintWriter
import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
class MainActivity : AppCompatActivity, IWithResultLauncher {
@@ -62,6 +67,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var rootView : MotionLayout;
private lateinit var _overlayContainer: FrameLayout;
private lateinit var _toastView: ToastView;
//Segment Containers
private lateinit var _fragContainerTopBar: FragmentContainerView;
@@ -92,6 +98,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment;
lateinit var _fragMainTutorial: TutorialFragment;
lateinit var _fragMainPlaylists: PlaylistsFragment;
lateinit var _fragMainPlaylist: PlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment;
@@ -203,7 +210,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragContainerVideoDetail = findViewById(R.id.fragment_overlay);
_fragContainerOverlay = findViewById(R.id.fragment_overlay_container);
_overlayContainer = findViewById(R.id.overlay_container);
//_overlayContainer.visibility = View.GONE;
_toastView = findViewById(R.id.toast_view);
//Initialize fragments
@@ -219,6 +226,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Main
_fragMainHome = HomeFragment.newInstance();
_fragMainTutorial = TutorialFragment.newInstance()
_fragMainSuggestions = SuggestionsFragment.newInstance();
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
@@ -310,6 +318,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
_fragMainPlaylistSearchResults.topBar = _fragTopBarSearch;
_fragMainChannel.topBar = _fragTopBarNavigation;
_fragMainTutorial.topBar = _fragTopBarNavigation;
_fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral;
_fragMainSources.topBar = _fragTopBarAdd;
_fragMainPlaylists.topBar = _fragTopBarGeneral;
@@ -321,11 +330,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragDownloads.topBar = _fragTopBarGeneral;
_fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroup.topBar = _fragTopBarNavigation;
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragBrowser.topBar = _fragTopBarNavigation;
fragCurrent = _fragMainHome;
val defaultTab = Settings.instance.tabs.mapNotNull {
@@ -407,6 +415,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStartedWithExternalFiles(this);
//startActivity(Intent(this, TestActivity::class.java));
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
navigate(_fragMainTutorial)
})
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
}
@@ -463,21 +481,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
_isVisible = true;
val videoToOpen = StateSaved.instance.videoToOpen;
if (_wasStopped) {
_wasStopped = false;
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
_fragVideoDetail.maximizeVideoDetail(true);
}
StateSaved.instance.setVideoToOpenNonBlocking(null);
}
}
}
override fun onPause() {
@@ -849,7 +852,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_orientationManager.disable();
StateApp.instance.mainAppDestroyed(this);
StateSaved.instance.setVideoToOpenBlocking(null);
}
inline fun <reified T> isFragmentActive(): Boolean {
@@ -965,6 +967,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) {
HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T;
SuggestionsFragment::class -> _fragMainSuggestions as T;
@@ -1010,6 +1013,70 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
fun requestNotificationPermissions(reason: String) {
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
reason, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
private val _toastQueue = ConcurrentLinkedQueue<ToastView.Toast>();
private var _toastJob: Job? = null;
fun showAppToast(toast: ToastView.Toast) {
synchronized(_toastQueue) {
_toastQueue.add(toast);
if(_toastJob?.isActive != true)
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
launchAppToastJob();
};
}
}
private suspend fun launchAppToastJob() {
Logger.i(TAG, "Starting appToast loop");
while(!_toastQueue.isEmpty()) {
val toast = _toastQueue.poll() ?: continue;
Logger.i(TAG, "Showing next toast (${toast.msg})");
lifecycleScope.launch(Dispatchers.Main) {
if (!_toastView.isVisible) {
Logger.i(TAG, "First showing toast");
_toastView.setToast(toast);
_toastView.show(true);
} else {
_toastView.setToastAnimated(toast);
}
}
if(toast.long)
delay(5000);
else
delay(3000);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
_toastView.hide(true) {
};
}
}
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
@@ -8,12 +8,15 @@ import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret
@@ -21,6 +24,9 @@ import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.base64UrlToByteArray
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import userpackage.Protocol.ExportBundle
@@ -29,6 +35,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -52,6 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
@@ -94,42 +102,57 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
return;
}
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
_loaderOverlay.show()
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return;
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
}
return@launch;
}
val processHandle = processSecret.toProcessHandle();
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.activities
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
@@ -12,6 +14,7 @@ import android.webkit.MimeTypeMap
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
@@ -21,14 +24,16 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers
@@ -46,6 +51,8 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String;
private lateinit var _imagePolycentric: ImageView;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _textSystem: TextView;
private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
@@ -63,28 +70,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
_buttonExport = findViewById(R.id.button_export);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
_textSystem = findViewById(R.id.text_system)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
saveIfRequired();
finish();
};
lifecycleScope.launch(Dispatchers.IO) {
try {
val processHandle = StatePolycentric.instance.processHandle!!;
Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io");
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
}
}
updateUI();
_imagePolycentric.setOnClickListener {
ImagePicker.with(this)
.cropSquare()
@@ -120,6 +112,37 @@ class PolycentricProfileActivity : AppCompatActivity() {
finish();
});
}
_textSystem.setOnLongClickListener {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("system", _textSystem.text)
clipboard.setPrimaryClip(clip)
return@setOnLongClickListener true
}
updateUI()
StatePolycentric.instance.processHandle?.let { processHandle ->
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide()
}
}
}
}
}
private fun saveIfRequired() {
@@ -128,13 +151,17 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false;
val username = _editName.text.toString();
if (username.length < 3) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
}
return@launch;
}
val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
}
return@launch;
}
@@ -219,6 +246,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private fun updateUI() {
val processHandle = StatePolycentric.instance.processHandle!!;
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system))
_textSystem.text = processHandle.system.key.toBase64Url()
_username = systemState.username;
_editName.text.clear();
_editName.text.append(_username);
@@ -1,8 +1,10 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
@@ -12,6 +14,8 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
@@ -33,6 +37,14 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@@ -58,6 +70,33 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
};
_buttonBack.setOnClickListener {
finish();
@@ -72,7 +111,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
@@ -90,6 +132,13 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
};
}
@@ -135,6 +184,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
resultLauncher.launch(intent);
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
@@ -14,7 +14,8 @@ enum class ChapterType(val value: Int) {
NORMAL(0),
SKIPPABLE(5),
SKIP(6);
SKIP(6),
SKIPONCE(7);
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.modifier
interface IModifierOptions {
val applyAuthClient: String?;
val applyCookieClient: String?;
val applyOtherHeaders: Boolean;
}
@@ -23,21 +23,31 @@ class JSRequest : IRequest {
_v8Options = options;
initialize(plugin, originalUrl, originalHeaders);
}
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?) {
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
val contextName = "ModifyRequestResponse";
val config = plugin.config;
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
Options(config, it);
}
Options(config, it, applyOtherHeadersByDefault);
} ?: Options(null, null, applyOtherHeadersByDefault);
initialize(plugin, originalUrl, originalHeaders);
}
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
val config = plugin.config;
url = _v8Url ?: originalUrl;
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options?.applyOtherHeaders ?: false) {
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
if (originalHeaders != null)
for (head in originalHeaders)
if (!headersToSet.containsKey(head.key))
headersToSet[head.key] = head.value;
headers = headersToSet;
}
else
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options != null) {
if(_v8Options.applyCookieClient != null && url != null) {
@@ -68,10 +78,18 @@ class JSRequest : IRequest {
class Options: IModifierOptions {
override val applyAuthClient: String?;
override val applyCookieClient: String?;
override val applyOtherHeaders: Boolean;
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
constructor(config: IV8PluginConfig, obj: V8ValueObject, applyOtherHeadersByDefault: Boolean = false) {
applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null);
applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null);
applyOtherHeaders = obj.getOrDefault(config, "applyOtherHeaders", "JSRequestModifier.options.applyOtherHeaders", applyOtherHeadersByDefault) ?: applyOtherHeadersByDefault;
}
constructor(applyAuthClient: String? = null, applyCookieClient: String? = null, applyOtherHeaders: Boolean = false) {
this.applyAuthClient = applyAuthClient;
this.applyCookieClient = applyCookieClient;
this.applyOtherHeaders = applyOtherHeaders;
}
}
@@ -40,6 +40,7 @@ class JSRequestModifier: IRequestModifier {
} as V8ValueObject;
val req = JSRequest(_plugin, result, url, headers);
result.close();
return req;
}
@@ -33,7 +33,7 @@ abstract class JSSource {
this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null);
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
}
@@ -6,11 +6,9 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor {
class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
protected val _obj: V8ValueObject;
override val isUnMuxed: Boolean;
@@ -138,7 +138,7 @@ class AirPlayCastingDevice : CastingDevice {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(3000);
delay(1000);
continue;
}
@@ -17,6 +17,8 @@ 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
@@ -50,6 +52,8 @@ class ChromecastCastingDevice : CastingDevice {
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -270,7 +274,6 @@ class ChromecastCastingDevice : CastingDevice {
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
@@ -283,152 +286,175 @@ class ChromecastCastingDevice : CastingDevice {
_launching = true;
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
Thread {
connectionState = CastConnectionState.CONNECTING;
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
Thread.sleep(3000);
continue;
}
val thread = _thread
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "Restarting threads because one of the threads has died")
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
}
}
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to Chromecast.");
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
continue;
}
localAddress = _socket?.localAddress;
try {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
continue;
}
getStatus();
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try {
handleMessage(message);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.start();
val factory = sslContext.socketFactory;
//Start ping loop
Thread {
Logger.i(TAG, "Started ping loop.")
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
val pingObject = JSONObject();
pingObject.put("type", "PING");
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(InetSocketAddress(usedRemoteAddress, port), 5000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
Thread.sleep(5000);
} catch (e: Throwable) {
_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(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((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());
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try {
handleMessage(message);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
}
Logger.i(TAG, "Stopped ping loop.");
}.start();
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
Logger.i(TAG, "Started.");
//Start ping loop
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject();
pingObject.put("type", "PING");
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
Thread.sleep(5000);
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
}
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) {
@@ -593,6 +619,8 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
@@ -1,8 +1,12 @@
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
@@ -26,22 +30,42 @@ import kotlinx.serialization.json.Json
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.spec.DHParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
enum class Opcode(val value: Byte) {
NONE(0),
PLAY(1),
PAUSE(2),
RESUME(3),
STOP(4),
SEEK(5),
PLAYBACK_UPDATE(6),
VOLUME_UPDATE(7),
SET_VOLUME(8),
PLAYBACK_ERROR(9),
SET_SPEED(10),
VERSION(11)
None(0),
Play(1),
Pause(2),
Resume(3),
Stop(4),
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8),
PlaybackError(9),
SetSpeed(10),
Version(11),
Ping(12),
Pong(13);
companion object {
private val _map = entries.associateBy { it.value }
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
}
}
class FCastCastingDevice : CastingDevice {
@@ -63,6 +87,7 @@ class FCastCastingDevice : CastingDevice {
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -94,7 +119,7 @@ class FCastCastingDevice : CastingDevice {
setTime(resumePosition);
setDuration(duration);
sendMessage(Opcode.PLAY, FCastPlayMessage(
send(Opcode.Play, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition,
@@ -118,7 +143,7 @@ class FCastCastingDevice : CastingDevice {
setTime(resumePosition);
setDuration(duration);
sendMessage(Opcode.PLAY, FCastPlayMessage(
send(Opcode.Play, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition,
@@ -134,7 +159,7 @@ class FCastCastingDevice : CastingDevice {
}
setVolume(volume);
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
@@ -143,7 +168,7 @@ class FCastCastingDevice : CastingDevice {
}
setSpeed(speed);
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed))
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
@@ -151,7 +176,7 @@ class FCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.SEEK, FCastSeekMessage(
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
@@ -161,7 +186,7 @@ class FCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.RESUME);
send(Opcode.Resume);
}
override fun pauseVideo() {
@@ -169,7 +194,7 @@ class FCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.PAUSE);
send(Opcode.Pause);
}
override fun stopVideo() {
@@ -177,7 +202,7 @@ class FCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.STOP);
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
@@ -201,7 +226,6 @@ class FCastCastingDevice : CastingDevice {
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
@@ -209,123 +233,152 @@ class FCastCastingDevice : CastingDevice {
_started = true;
Logger.i(TAG, "Starting...");
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
Thread {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
Thread.sleep(3000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
break;
} catch (e: Throwable) {
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
}
}
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket = Socket(usedRemoteAddress, port);
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to FastCast.", e);
}
} catch (e: IOException) {
_socket?.close();
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
var exceptionOccurred = false;
while (_scopeIO?.isActive == true && !exceptionOccurred) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.entries.first { it.value == opcode }, json);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
exceptionOccurred = true;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
exceptionOccurred = true;
}
}
try {
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.start();
ensureThreadStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadStarted() {
val adrs = addresses ?: return;
val thread = _thread
if (thread == null || !thread.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.")
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)
}
}
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.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(InetSocketAddress(usedRemoteAddress, port), 5000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: IOException) {
_socket?.close();
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
var exceptionOccurred = false;
while (_scopeIO?.isActive == true && !exceptionOccurred) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
exceptionOccurred = true;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
exceptionOccurred = true;
}
}
try {
_socket?.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() };
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) {
Opcode.PLAYBACK_UPDATE -> {
Opcode.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
@@ -339,7 +392,7 @@ class FCastCastingDevice : CastingDevice {
else -> false
}
}
Opcode.VOLUME_UPDATE -> {
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
@@ -348,7 +401,7 @@ class FCastCastingDevice : CastingDevice {
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PLAYBACK_ERROR -> {
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
@@ -357,7 +410,7 @@ class FCastCastingDevice : CastingDevice {
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.VERSION -> {
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
@@ -367,72 +420,49 @@ class FCastCastingDevice : CastingDevice {
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
else -> { }
}
}
private fun sendMessage(opcode: Opcode) {
private fun send(opcode: Opcode, message: String? = null) {
try {
val size = 1;
val outputStream = _outputStream;
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
return;
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 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);
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
Log.d(TAG, "Sent $size bytes.");
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send message.", e);
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
private inline fun <reified T> sendMessage(opcode: Opcode, message: T) {
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
val data: ByteArray;
var jsonString: String? = null;
if (message != null) {
jsonString = json.encodeToString(message);
data = jsonString.encodeToByteArray();
} else {
data = ByteArray(0);
}
val size = 1 + data.size;
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
return;
}
val serializedSizeLE = ByteArray(4);
serializedSizeLE[0] = (size and 0xff).toByte();
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
outputStream.write(serializedSizeLE);
val opcodeBytes = ByteArray(1);
opcodeBytes[0] = opcode.value;
outputStream.write(opcodeBytes);
if (data.isNotEmpty()) {
outputStream.write(data);
}
Log.d(TAG, "Sent $size bytes: '$jsonString'.");
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send message.", e);
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
@@ -441,6 +471,8 @@ class FCastCastingDevice : CastingDevice {
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
val socket = _socket;
val scopeIO = _scopeIO;
@@ -471,7 +503,65 @@ class FCastCastingDevice : CastingDevice {
}
companion object {
val TAG = "FastCastCastingDevice";
val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return FCastEncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
}
}
@@ -205,11 +205,20 @@ class StateCasting {
}
fun onResume() {
val resumeCastingDevice = _resumeCastingDevice
if (resumeCastingDevice != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
_resumeCastingDevice = null
Log.i(TAG, "_resumeCastingDevice set to null onResume")
val ad = activeDevice
if (ad != null) {
if (ad is FCastCastingDevice) {
ad.ensureThreadStarted()
} else if (ad is ChromecastCastingDevice) {
ad.ensureThreadsStarted()
}
} else {
val resumeCastingDevice = _resumeCastingDevice
if (resumeCastingDevice != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
_resumeCastingDevice = null
Log.i(TAG, "_resumeCastingDevice set to null onResume")
}
}
}
@@ -50,4 +50,23 @@ data class FCastPlaybackErrorMessage(
@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
)
@@ -6,11 +6,12 @@ import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
@@ -25,7 +26,11 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -93,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref)
val eventPointer = processHandle.post(comment, ref)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
@@ -1,43 +1,34 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.QRCaptureActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.UUID
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button;
private lateinit var _buttonScanQR: Button;
private lateinit var _buttonAdd: ImageButton;
private lateinit var _buttonScanQR: ImageButton;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
@@ -80,6 +71,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
@@ -133,17 +133,17 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
StateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
_sliderVolume.value = it.toFloat();
_sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
};
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat();
_sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo);
};
StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
StateCasting.instance.onActiveDeviceDurationChanged.subscribe {
_sliderPosition.valueTo = it.toFloat();
_sliderPosition.valueTo = it.toFloat().coerceAtLeast(1.0f);
};
_device = StateCasting.instance.activeDevice;
@@ -152,6 +152,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
setLoading(!isConnected);
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); };
updateDevice();
};
updateDevice();
@@ -181,10 +182,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
}
_textName.text = d.name;
_sliderVolume.value = d.volume.toFloat();
_sliderPosition.valueFrom = 0.0f;
_sliderPosition.valueTo = d.duration.toFloat();
_sliderPosition.value = d.time.toFloat();
_sliderVolume.valueFrom = 0.0f;
_sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
_sliderPosition.valueTo = d.duration.toFloat().coerceAtLeast(1.0f);
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
if (d.canSetVolume) {
_layoutVolumeAdjustable.visibility = View.VISIBLE;
@@ -193,6 +195,44 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_layoutVolumeAdjustable.visibility = View.GONE;
_layoutVolumeFixed.visibility = View.VISIBLE;
}
val interactiveControls = listOf(
_sliderPosition,
_sliderVolume,
_buttonPrevious,
_buttonPlay,
_buttonPause,
_buttonStop,
_buttonNext
)
when (d.connectionState) {
CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
}
private fun enableControls(views: List<View>) {
views.forEach { enableControl(it) }
}
private fun enableControl(view: View) {
view.alpha = 1.0f
view.isEnabled = true
}
private fun disableControls(views: List<View>) {
views.forEach { disableControl(it) }
}
private fun disableControl(view: View) {
view.alpha = 0.4f
view.isEnabled = false
}
private fun setLoading(isLoading: Boolean) {
@@ -337,8 +337,10 @@ class VideoDownload {
});
}
var wasSuccesful = false;
try {
awaitAll(*sourcesToDownload.toTypedArray());
wasSuccesful = true;
}
catch(runtimeEx: RuntimeException) {
if(runtimeEx.cause != null)
@@ -349,6 +351,29 @@ class VideoDownload {
catch(ex: Throwable) {
throw ex;
}
finally {
if(!wasSuccesful) {
try {
if(videoFilePath != null) {
val remainingVideo = File(videoFilePath!!);
if (remainingVideo.exists()) {
Logger.i(TAG, "Deleting remaining video file");
remainingVideo.delete();
}
}
if(audioFilePath != null) {
val remainingAudio = File(audioFilePath!!);
if (remainingAudio.exists()) {
Logger.i(TAG, "Deleting remaining audio file");
remainingAudio.delete();
}
}
}
catch(iex: Throwable) {
Logger.e(TAG, "Failed to delete files after failure:\n${iex.message}", iex);
}
}
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
@@ -289,10 +289,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttonDefinitions.find { d -> d.id == it.id }
}.toMutableList()
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
//Add unconfigured tabs with default values
buttonDefinitions.forEach { buttonDefinition ->
if (!Settings.instance.tabs.any { it.id == buttonDefinition.id }) {
newCurrentButtonDefinitions.add(buttonDefinition)
}
}
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.faq, canToggle = false, { false }, {
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
}
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = false, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
}))
@@ -349,7 +356,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
@@ -418,6 +418,7 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannel.text = channel.name;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
//TODO: Find a better way to access the adapter fragments..
@@ -465,7 +466,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
@@ -15,18 +15,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.others.TagsView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -52,6 +51,7 @@ class HistoryFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
_view?.setPager(StateHistory.instance.getHistoryPager());
(topBar as NavigationTopBarFragment?)?.onShown("History");
}
@SuppressLint("ViewConstructor")
@@ -314,8 +314,8 @@ class PostDetailFragment : MainFragment {
private fun updatePolycentricRating() {
_rating.visibility = View.GONE;
val value = _post?.id?.value ?: _postOverview?.id?.value ?: return;
val ref = Models.referenceFromBuffer(value.toByteArray());
val ref = Models.referenceFromBuffer((_post?.url ?: _postOverview?.url)?.toByteArray() ?: return)
val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.toByteArray()
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
@@ -333,7 +333,8 @@ class PostDetailFragment : MainFragment {
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
)
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (version != _version) {
@@ -342,8 +343,8 @@ class PostDetailFragment : MainFragment {
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref);
val hasDisliked = StatePolycentric.instance.hasDisliked(ref);
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
if (version != _version) {
@@ -468,9 +469,7 @@ class PostDetailFragment : MainFragment {
if (_postOverview == null) {
fetchPolycentricProfile();
updatePolycentricRating();
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
}
updateCommentType(true);
@@ -489,9 +488,7 @@ class PostDetailFragment : MainFragment {
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textContent.text = value.description.fixHtmlWhitespace();
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
updatePolycentricRating();
fetchPolycentricProfile();
@@ -636,12 +633,12 @@ class PostDetailFragment : MainFragment {
if (cachedPolycentricProfile?.profile == null) {
_layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate);
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate);
_creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto());
}
private fun fetchPost() {
@@ -665,14 +662,16 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val post = _post;
val idValue = post?.id?.value
if (idValue == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because id was null")
val ref = (_post?.url ?: _postOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.toByteArray()
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear();
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post.url, Models.referenceFromBuffer(idValue.toByteArray())); };
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun updateCommentType(reloadComments: Boolean) {
@@ -454,6 +454,7 @@ class SourceDetailFragment : MainFragment() {
}
});
}
private fun checkForUpdatesSource() {
val c = _config ?: return;
val sourceUrl = c.sourceUrl ?: return;
@@ -39,10 +39,12 @@ class SourcesFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
if(topBar is AddTopBarFragment)
if(topBar is AddTopBarFragment) {
(topBar as AddTopBarFragment).onAdd.clear();
(topBar as AddTopBarFragment).onAdd.subscribe {
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
};
}
_view?.reloadSources();
}
@@ -4,35 +4,34 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.getSystemService
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.dp
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.viewholders.CreatorBarViewHolder
import com.futo.platformplayer.views.overlays.CreatorSelectOverlay
import com.futo.platformplayer.views.overlays.ImageVariableOverlay
import com.futo.platformplayer.views.overlays.OverlayTopbar
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
@@ -60,6 +59,11 @@ class SubscriptionGroupFragment : MainFragment() {
return view;
}
override fun onHide() {
super.onHide();
_view?.onHide();
}
companion object {
private const val TAG = "SourcesFragment";
fun newInstance() = SubscriptionGroupFragment().apply {}
@@ -69,6 +73,7 @@ class SubscriptionGroupFragment : MainFragment() {
private class SubscriptionGroupView: ConstraintLayout {
private val _fragment: SubscriptionGroupFragment;
private val _topbar: OverlayTopbar;
private val _textGroupTitleContainer: LinearLayout;
private val _textGroupTitle: TextView;
private val _imageGroup: ShapeableImageView;
@@ -81,26 +86,25 @@ class SubscriptionGroupFragment : MainFragment() {
private val _buttonSettings: ImageButton;
private val _buttonDelete: ImageButton;
private val _enabledCreators: ArrayList<IPlatformChannel> = arrayListOf();
private val _disabledCreators: ArrayList<IPlatformChannel> = arrayListOf();
private val _enabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _disabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _buttonAddCreator: FrameLayout;
private val _containerEnabled: LinearLayout;
private val _containerDisabled: LinearLayout;
private val _enabledCreators: ArrayList<IPlatformChannel> = arrayListOf();
private val _enabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _recyclerCreatorsEnabled: AnyAdapterView<IPlatformChannel, CreatorBarViewHolder>;
private val _recyclerCreatorsDisabled: AnyAdapterView<IPlatformChannel, CreatorBarViewHolder>;
private val _overlay: FrameLayout;
private var _group: SubscriptionGroup? = null;
private var _didDelete: Boolean = false;
constructor(context: Context, fragment: SubscriptionGroupFragment): super(context) {
inflate(context, R.layout.fragment_subscriptions_group, this);
_fragment = fragment;
_overlay = findViewById(R.id.overlay);
_topbar = findViewById(R.id.topbar);
_searchBar = findViewById(R.id.search_bar);
_textGroupTitleContainer = findViewById(R.id.text_group_title_container);
_textGroupTitle = findViewById(R.id.text_group_title);
@@ -110,33 +114,51 @@ class SubscriptionGroupFragment : MainFragment() {
_textGroupMeta = findViewById(R.id.text_group_meta);
_buttonSettings = findViewById(R.id.button_settings);
_buttonDelete = findViewById(R.id.button_delete);
_buttonAddCreator = findViewById(R.id.button_creator_add);
_imageGroup.setBackgroundColor(Color.GRAY);
_topbar.onClose.subscribe {
fragment.close(true);
}
_buttonAddCreator.setOnClickListener {
addCreators();
}
val dp6 = 6.dp(resources);
_imageGroup.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCorners(CornerFamily.ROUNDED, dp6.toFloat())
.build()
_containerEnabled = findViewById(R.id.container_enabled);
_containerDisabled = findViewById(R.id.container_disabled);
_recyclerCreatorsEnabled = findViewById<RecyclerView>(R.id.recycler_creators_enabled).asAny(_enabledCreatorsFiltered) {
it.itemView.setPadding(0, dp6, 0, dp6);
it.onClick.subscribe { channel ->
disableCreator(channel);
//disableCreator(channel);
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete", "Are you sure you want to delete\n[${channel.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
_group?.let {
it.urls.remove(channel.url);
save();
reloadCreators(it);
}
}, UIDialogs.ActionStyle.DANGEROUS))
};
}
/*
_recyclerCreatorsDisabled = findViewById<RecyclerView>(R.id.recycler_creators_disabled).asAny(_disabledCreatorsFiltered) {
it.itemView.setPadding(0, dp6, 0, dp6);
it.onClick.subscribe { channel ->
enableCreator(channel);
};
}
}*/
_recyclerCreatorsEnabled.view.layoutManager = GridLayoutManager(context, 5).apply {
this.orientation = LinearLayoutManager.VERTICAL;
};
/*
_recyclerCreatorsDisabled.view.layoutManager = GridLayoutManager(context, 5).apply {
this.orientation = LinearLayoutManager.VERTICAL;
};
};*/
_textGroupTitleContainer.setOnClickListener {
_group?.let { editName(it) };
@@ -154,10 +176,15 @@ class SubscriptionGroupFragment : MainFragment() {
}
_buttonDelete.setOnClickListener {
_group?.let {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(it.id);
_group?.let { g ->
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${g.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id);
_didDelete = true;
fragment.close(true);
}, UIDialogs.ActionStyle.DANGEROUS))
};
fragment.close(true);
}
_buttonSettings.visibility = View.GONE;
@@ -165,6 +192,12 @@ class SubscriptionGroupFragment : MainFragment() {
filterCreators();
}
_topbar.setButtons(
Pair(R.drawable.ic_share) {
UIDialogs.toast(context, "Coming soon");
}
);
setGroup(null);
}
@@ -208,9 +241,44 @@ class SubscriptionGroupFragment : MainFragment() {
overlay.removeAllViews();
}
}
fun addCreators() {
val overlay = CreatorSelectOverlay(context, _enabledCreators.map { it.url });
_overlay.removeAllViews();
_overlay.addView(overlay);
_overlay.alpha = 0f
_overlay.visibility = View.VISIBLE;
_overlay.animate().alpha(1f).setDuration(300).start();
overlay.onSelected.subscribe {
_group?.let { g ->
if(g.urls.isEmpty() && g.image == null) {
//Obtain image
for(sub in it) {
val sub = StateSubscriptions.instance.getSubscription(sub);
if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
g.image?.setImageView(_imageGroup);
g.image?.setImageView(_imageGroupBackground);
break;
}
}
}
for(url in it) {
if(!g.urls.contains(url))
g.urls.add(url);
}
save();
reloadCreators(g);
}
};
overlay.onClose.subscribe {
_overlay.visibility = View.GONE;
overlay.removeAllViews();
}
}
fun setGroup(group: SubscriptionGroup?) {
_didDelete = false;
_group = group;
_textGroupTitle.text = group?.name;
@@ -227,76 +295,39 @@ class SubscriptionGroupFragment : MainFragment() {
reloadCreators(group);
}
fun onHide() {
if(!_didDelete && _group != null && StateSubscriptionGroups.instance.getSubscriptionGroup(_group!!.id) === null) {
UIDialogs.toast(context, "Group creation cancelled");
}
}
@SuppressLint("NotifyDataSetChanged")
private fun reloadCreators(group: SubscriptionGroup?) {
_enabledCreators.clear();
_disabledCreators.clear();
//_disabledCreators.clear();
if(group != null) {
val urls = group.urls.toList();
val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel }
_enabledCreators.addAll(subs.filter { urls.contains(it.url) });
_disabledCreators.addAll(subs.filter { !urls.contains(it.url) });
}
updateMeta();
filterCreators();
}
private fun filterCreators() {
val query = _searchBar.textSearch.text.toString().lowercase();
val filteredEnabled = _enabledCreators.filter { it.name.lowercase().contains(query) };
val filteredDisabled = _disabledCreators.filter { it.name.lowercase().contains(query) };
//Optimize
_enabledCreatorsFiltered.clear();
_enabledCreatorsFiltered.addAll(filteredEnabled);
_disabledCreatorsFiltered.clear();
_disabledCreatorsFiltered.addAll(filteredDisabled);
_recyclerCreatorsEnabled.notifyContentChanged();
_recyclerCreatorsDisabled.notifyContentChanged();
}
private fun enableCreator(channel: IPlatformChannel) {
val index = _disabledCreatorsFiltered.indexOf(channel);
if (index >= 0) {
_disabledCreators.remove(channel)
_disabledCreatorsFiltered.remove(channel);
_recyclerCreatorsDisabled.adapter.notifyItemRangeRemoved(index);
_enabledCreators.add(channel);
_enabledCreatorsFiltered.add(channel);
_recyclerCreatorsEnabled.adapter.notifyItemInserted(_enabledCreatorsFiltered.size - 1);
_group?.let {
if(!it.urls.contains(channel.url)) {
it.urls.add(channel.url);
save();
}
}
updateMeta();
}
}
private fun disableCreator(channel: IPlatformChannel) {
val index = _enabledCreatorsFiltered.indexOf(channel);
if (index >= 0) {
_enabledCreators.remove(channel)
_enabledCreatorsFiltered.removeAt(index);
_recyclerCreatorsEnabled.adapter.notifyItemRangeRemoved(index);
_disabledCreators.add(channel);
_disabledCreatorsFiltered.add(channel);
_recyclerCreatorsDisabled.adapter.notifyItemInserted(_disabledCreatorsFiltered.size - 1);
_group?.let {
it.urls.remove(channel.url);
save();
}
updateMeta();
}
}
private fun updateMeta() {
_textGroupMeta.text = "${_enabledCreators.size} creators";
_textGroupMeta.text = "${_group?.urls?.size} creators";
}
}
}
@@ -107,12 +107,14 @@ class SubscriptionGroupListFragment : MainFragment() {
updateGroups();
}
if(topBar is AddTopBarFragment)
if(topBar is AddTopBarFragment) {
(topBar as AddTopBarFragment).onAdd.clear();
(topBar as AddTopBarFragment).onAdd.subscribe {
_overlay?.let {
UISlideOverlays.showCreateSubscriptionGroup(it)
}
};
}
}
private fun updateGroups() {
@@ -10,6 +10,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.IPlatformClient
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.video.IPlatformVideo
@@ -52,6 +53,7 @@ class SubscriptionsFeedFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true;
private var _view: SubscriptionsFeedView? = null;
private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
@@ -72,6 +74,8 @@ class SubscriptionsFeedFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = SubscriptionsFeedView(this, inflater, _cachedRecyclerData);
_view = view;
if(_group != null)
view.selectSubgroup(_group);
return view;
}
@@ -80,6 +84,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val view = _view;
if (view != null) {
_cachedRecyclerData = view.recyclerData;
_group = view.subGroup;
view.cleanup();
_view = null;
}
@@ -100,18 +105,21 @@ class SubscriptionsFeedFragment : MainFragment() {
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
private var _subGroup: SubscriptionGroup? = null;
var subGroup: SubscriptionGroup? = null;
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
StateSubscriptions.instance.onFeedProgress.subscribe(this) { id, progress, total ->
if(subGroup?.id == id)
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
}
}
}
}
StateSubscriptions.instance.global.onUpdateProgress.subscribe(this) { progress, total ->
};
StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { _, added ->
@@ -137,8 +145,9 @@ class SubscriptionsFeedFragment : MainFragment() {
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen) {
loadResults(false);
}
else if(recyclerData.results.size == 0) {
loadCache();
setLoading(false);
@@ -162,14 +171,14 @@ class SubscriptionsFeedFragment : MainFragment() {
}
}
if (!StateSubscriptions.instance.isGlobalUpdating) {
if (!StateSubscriptions.instance.global.isGlobalUpdating) {
finishRefreshLayoutLoader();
}
}
override fun cleanup() {
super.cleanup()
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.remove(this);
StateSubscriptions.instance.global.onUpdateProgress.remove(this);
StateSubscriptions.instance.onSubscriptionsChanged.remove(this);
}
@@ -194,8 +203,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null;
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
val group = subGroup;
if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group);
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
@@ -203,9 +213,10 @@ class SubscriptionsFeedFragment : MainFragment() {
throw RateLimitException(rateLimitPlugins.map { it.key.id });
}
_bypassRateLimit = false;
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh, group);
val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any())
handleExceptions(currentExs);
@@ -252,6 +263,11 @@ class SubscriptionsFeedFragment : MainFragment() {
}
};
fun selectSubgroup(g: SubscriptionGroup?) {
if(g != null)
_subscriptionBar?.selectGroup(g);
}
private fun initializeToolbarContent() {
_subscriptionBar = SubscriptionBar(context).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -261,8 +277,17 @@ class SubscriptionsFeedFragment : MainFragment() {
if(g is SubscriptionGroup.Add)
UISlideOverlays.showCreateSubscriptionGroup(_overlayContainer);
else {
_subGroup = g;
loadCache(); //TODO: Proper subset update
subGroup = g;
setProgress(0, 0);
if(Settings.instance.subscriptions.fetchOnTabOpen) {
loadCache();
loadResults(false);
}
else if(g != null && StateSubscriptions.instance.getFeed(g.id) != null) {
loadResults(false);
}
else
loadCache();
}
};
_subscriptionBar?.onHoldGroup?.subscribe { g ->
@@ -303,7 +328,7 @@ class SubscriptionsFeedFragment : MainFragment() {
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = _subGroup;
val filterGroup = subGroup;
return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
@@ -403,7 +428,7 @@ class SubscriptionsFeedFragment : MainFragment() {
context?.let {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if (exs.size <= 8) {
if (exs.size <= 3) {
for (ex in exs) {
var toShow = ex;
var channel: String? = null;
@@ -413,12 +438,11 @@ class SubscriptionsFeedFragment : MainFragment() {
}
Logger.e(TAG, "Channel [${channel}] failed", ex);
if (toShow is PluginException)
UIDialogs.toast(
it,
UIDialogs.appToast(
context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", toShow.config.name).replace("{message}", toShow.message ?: "")
);
else
UIDialogs.toast(it, ex.message ?: "");
UIDialogs.appToast(ex.message ?: "");
}
}
else {
@@ -429,7 +453,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.map { it!! }
.toList();
for(distinctPluginFail in failedPlugins)
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
UIDialogs.appToast(context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle exceptions", e)
@@ -0,0 +1,208 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.views.pills.WidePillButton
import java.time.OffsetDateTime
class TutorialFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _view: TutorialView? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
(topBar as NavigationTopBarFragment?)?.onShown(getString(R.string.tutorials));
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = TutorialView(this, inflater);
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view = null;
}
@SuppressLint("ViewConstructor")
class TutorialView : LinearLayout {
val fragment: TutorialFragment
constructor(fragment: TutorialFragment, inflater: LayoutInflater) : super(inflater.context) {
this.fragment = fragment
orientation = VERTICAL
addView(createHeader("Initial setup"))
initialSetupVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
addView(createHeader("Features"))
featuresVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
}
private fun createHeader(t: String): TextView {
return TextView(context).apply {
textSize = 24.0f
typeface = resources.getFont(R.font.inter_regular)
text = t
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 10.dp(resources), 15.dp(resources), 12.dp(resources))
}
}
}
private fun createTutorialPill(iconPrefix: Int, t: String): WidePillButton {
return WidePillButton(context).apply {
setIconPrefix(iconPrefix)
setText(t)
setIconSuffix(R.drawable.ic_play_notif)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 0, 15.dp(resources), 12.dp(resources))
}
}
}
}
class TutorialVideoSourceDescriptor(url: String, duration: Long) : VideoUnMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> = arrayOf(
VideoUrlSource("1080p", url, 1920, 1080, duration, "video/mp4")
)
override val audioSources: Array<IAudioSource> = arrayOf()
}
class TutorialVideo(
uuid: String,
override val name: String,
override val description: String,
thumbnailUrl: String,
videoUrl: String,
override val duration: Long
) : IPlatformVideoDetails {
override val id: PlatformID = PlatformID("tutorial", uuid)
override val contentType: ContentType = ContentType.MEDIA
override val preview: IVideoSourceDescriptor? = null
override val live: IVideoSource? = null
override val dash: IDashManifestSource? = null
override val hls: IHLSManifestSource? = null
override val subtitles: List<ISubtitleSource> = emptyList()
override val shareUrl: String = videoUrl
override val url: String = videoUrl
override val datetime: OffsetDateTime? = OffsetDateTime.parse("2023-12-18T00:00:00Z")
override val thumbnails: Thumbnails = Thumbnails(arrayOf(Thumbnail(thumbnailUrl)))
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("tutorial", "f422ced6-b551-4b62-818e-27a4f5f4918a"), "Grayjay", "", "https://releases.grayjay.app/tutorials/author.jpeg")
override val isLive: Boolean = false
override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration)
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
override fun getPlaybackTracker(): IPlaybackTracker? {
return null
}
}
companion object {
val TAG = "HomeFragment";
fun newInstance() = TutorialFragment().apply {}
val initialSetupVideos = listOf(
TutorialVideo(
uuid = "228be579-ec52-4d93-b9eb-ca74ec08c58a",
name = "How to install",
description = "Learn how to install Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-install.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-install.mp4",
duration = 52
),
TutorialVideo(
uuid = "3b99ebfe-2640-4643-bfe0-a0cf04261fc5",
name = "Getting started",
description = "Learn how to get started with Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/getting-started.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/getting-started.mp4",
duration = 50
),
TutorialVideo(
uuid = "793aa009-516c-4581-b82f-a8efdfef4c27",
name = "Is Grayjay free?",
description = "Learn how Grayjay is monetized.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/pay.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/pay.mp4",
duration = 52
)
)
val featuresVideos = listOf(
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Searching",
description = "Learn about searching in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/search.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/search.mp4",
duration = 39
),
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Comments",
description = "Learn about Polycentric comments in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/polycentric.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/polycentric.mp4",
duration = 64
),
TutorialVideo(
uuid = "94d36959-e3fc-4c24-a988-89147067a179",
name = "Casting",
description = "Learn about casting in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-cast.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-cast.mp4",
duration = 79
)
)
}
}
@@ -25,8 +25,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StateSaved
import com.futo.platformplayer.states.VideoToOpen
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
class VideoDetailFragment : MainFragment {
@@ -372,11 +370,6 @@ class VideoDetailFragment : MainFragment {
Logger.v(TAG, "shouldStop: $shouldStop");
if(shouldStop) {
_viewDetail?.let {
val v = it.video ?: return@let;
StateSaved.instance.setVideoToOpenBlocking(VideoToOpen(v.url, (it.lastPositionMilliseconds / 1000.0f).toLong()));
}
_viewDetail?.onStop();
StateCasting.instance.onStop();
Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
@@ -50,6 +50,7 @@ import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetExcept
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
@@ -148,6 +149,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
@@ -251,6 +254,7 @@ class VideoDetailView : ConstraintLayout {
private val _layoutRating: LinearLayout;
private val _imageDislikeIcon: ImageView;
private val _imageLikeIcon: ImageView;
private val _layoutToggleCommentSection: LinearLayout;
private val _monetization: MonetizationView;
@@ -327,6 +331,7 @@ class VideoDetailView : ConstraintLayout {
_upNext = findViewById(R.id.up_next);
_textCommentType = findViewById(R.id.text_comment_type);
_toggleCommentType = findViewById(R.id.toggle_comment_type);
_layoutToggleCommentSection = findViewById(R.id.layout_toggle_comment_section);
_overlayContainer = findViewById(R.id.overlay_container);
_overlay_quality_container = findViewById(R.id.videodetail_quality_overview);
@@ -433,18 +438,21 @@ class VideoDetailView : ConstraintLayout {
_buttonPins.alwaysShowLastButton = true;
var buttonMore: RoundButton? = null;
buttonMore = RoundButton(context, R.drawable.ic_menu, "More", TAG_MORE) {
_slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected ->
buttonMore = RoundButton(context, R.drawable.ic_menu, context.getString(R.string.more), TAG_MORE) {
_slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE), false) {selected ->
_buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray());
_buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray())
_buttonPinStore.save();
}
};
};
_buttonMore = buttonMore;
updateMoreButtons();
_channelButton.setOnClickListener {
if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener
}
(video?.author ?: _searchVideo?.author)?.let {
fragment.navigate<ChannelFragment>(it);
fragment.lifecycleScope.launch {
@@ -459,20 +467,29 @@ class VideoDetailView : ConstraintLayout {
_cast.onSettingsClick.subscribe { showVideoSettings() };
_player.onVideoSettings.subscribe { showVideoSettings() };
_player.onToggleFullScreen.subscribe(::handleFullScreen);
_player.onChapterChanged.subscribe { chapter, isScrub ->
val onChapterChanged = { chapter: IChapter?, isScrub: Boolean ->
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
_layoutSkip.visibility = GONE;
if(!isScrub) {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
}
else if(chapter?.type == ChapterType.SKIP) {
_player.seekTo((chapter.timeEnd * 1000).toLong());
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
val ad = StateCasting.instance.activeDevice
if (ad != null) {
ad.seekVideo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
}
}
}
};
_player.onChapterChanged.subscribe(onChapterChanged);
_cast.onChapterChanged.subscribe(onChapterChanged);
_cast.onMinimizeClick.subscribe {
_player.setFullScreen(false);
@@ -667,9 +684,17 @@ class VideoDetailView : ConstraintLayout {
};
_layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
val ad = StateCasting.instance.activeDevice;
if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
}
}
}
}
@@ -750,6 +775,7 @@ class VideoDetailView : ConstraintLayout {
Logger.e(TAG, "Failed to reopen live chat", ex);
}
}
_slideUpOverlay?.hide();
} else null,
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
@@ -762,6 +788,7 @@ class VideoDetailView : ConstraintLayout {
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
@@ -774,11 +801,13 @@ class VideoDetailView : ConstraintLayout {
preventPictureInPicture = true;
shareVideo();
};
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let {
@@ -786,9 +815,11 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail();
};
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo();
_slideUpOverlay?.hide();
}).filterNotNull();
if(!_buttonPinStore.getAllValues().any())
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
@@ -824,14 +855,19 @@ class VideoDetailView : ConstraintLayout {
}
}
}
private val _historyIndexLock = Mutex(false);
suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){
val current = _historyIndex;
if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true)!!;
_historyIndex = index;
return@withContext index;
_historyIndexLock.withLock {
val current = _historyIndex;
if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true)!!;
_historyIndex = index;
return@withContext index;
}
return@withContext current;
}
return@withContext current;
}
@@ -1092,7 +1128,7 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main);
}
@OptIn(ExperimentalCoroutinesApi::class)
//@OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
@@ -1115,6 +1151,7 @@ class VideoDetailView : ConstraintLayout {
if(videoDetail is VideoLocal) {
videoLocal = videoDetail;
video = videoDetail;
this.video = video;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex ->
if(ex != null) {
@@ -1145,10 +1182,12 @@ class VideoDetailView : ConstraintLayout {
//TODO: Implement video.getContentChapters()
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters);
_cast.setChapters(chapters);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null);
_cast.setChapters(null);
/*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
@@ -1180,12 +1219,17 @@ class VideoDetailView : ConstraintLayout {
};
}
val ref = video.id.value?.let { Models.referenceFromBuffer(it.toByteArray()) };
_addCommentView.setContext(video.url, ref);
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = video.id.value?.toByteArray()
_addCommentView.setContext(video.url, ref)
_player.setMetadata(video.name, video.author.name);
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
if (video is TutorialFragment.TutorialVideo) {
_toggleCommentType.setValue(false, false);
} else {
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
}
updateCommentType(true);
//UI
@@ -1238,57 +1282,54 @@ class VideoDetailView : ConstraintLayout {
_rating.onLikeDislikeUpdated.remove(this);
if (ref != null) {
_rating.visibility = View.GONE;
_rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
)
);
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref);
val hasDisliked = StatePolycentric.instance.hasDisliked(ref);
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
} else {
_rating.visibility = View.GONE;
}
when (video.rating) {
@@ -1335,28 +1376,30 @@ class VideoDetailView : ConstraintLayout {
updateQueueState();
fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail);
if (video !is TutorialFragment.TutorialVideo) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail);
withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
}
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
}
}
@@ -1372,6 +1415,20 @@ class VideoDetailView : ConstraintLayout {
_player.updateNextPrevious();
updateMoreButtons();
if (videoDetail is TutorialFragment.TutorialVideo) {
_buttonSubscribe.visibility = View.GONE
_buttonMore.visibility = View.GONE
_buttonPins.visibility = View.GONE
_layoutRating.visibility = View.GONE
_layoutToggleCommentSection.visibility = View.GONE
} else {
_buttonSubscribe.visibility = View.VISIBLE
_buttonMore.visibility = View.VISIBLE
_buttonPins.visibility = View.VISIBLE
_layoutRating.visibility = View.VISIBLE
_layoutToggleCommentSection.visibility = View.VISIBLE
}
}
fun loadLiveChat(video: IPlatformVideoDetails) {
_liveChat?.stop();
@@ -1434,12 +1491,12 @@ class VideoDetailView : ConstraintLayout {
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
_didStop = false;
val video = video ?: return;
val video = (videoLocal ?: video) ?: return;
try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource;
val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) {
@@ -1467,6 +1524,8 @@ class VideoDetailView : ConstraintLayout {
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
}
else
@@ -1474,6 +1533,7 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource;
_lastAudioSource = audioSource;
_lastSubtitleSource = subtitleSource;
}
catch(ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex);
@@ -1914,7 +1974,9 @@ class VideoDetailView : ConstraintLayout {
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, Models.referenceFromBuffer(idValue.toByteArray())); };
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = video.id.value?.toByteArray()
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun fetchVideo() {
Logger.i(TAG, "fetchVideo")
@@ -2176,9 +2238,11 @@ class VideoDetailView : ConstraintLayout {
val v = video ?: return;
val currentTime = System.currentTimeMillis();
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val history = getHistoryIndex(v);
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
if (v !is TutorialFragment.TutorialVideo) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val history = getHistoryIndex(v);
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
}
}
_lastPositionSaveTime = currentTime;
}
@@ -2261,7 +2325,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
val username = cachedPolycentricProfile?.profile?.systemState?.username
@@ -0,0 +1,23 @@
package com.futo.platformplayer.functional
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
//TODO: Integrate this better?
class CentralizedFeed {
var lock = Object();
var feed: ReusablePager<IPlatformContent>? = null;
var isGlobalUpdating: Boolean = false;
var exceptions: List<Throwable> = listOf();
var lastProgress: Int = 0;
var lastTotal: Int = 0;
val onUpdateProgress = Event2<Int, Int>();
val onUpdated = Event0();
val onUpdatedOnce = Event1<Throwable?>();
val onException = Event1<List<Throwable>>();
}
@@ -48,11 +48,16 @@ class Subscription {
var playbackSeconds: Int = 0;
var playbackViews: Int = 0;
var isOther = false;
constructor(channel : SerializedChannel) {
this.channel = channel;
}
fun isChannel(url: String): Boolean {
return channel.url == url || channel.urlAlternatives.contains(url);
}
fun shouldFetchVideos() = doFetchVideos &&
(lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
(lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
@@ -63,10 +68,16 @@ class Subscription {
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun save() {
StateSubscriptions.instance.saveSubscription(this);
if(isOther)
StateSubscriptions.instance.saveSubscriptionOther(this);
else
StateSubscriptions.instance.saveSubscription(this);
}
fun saveAsync() {
StateSubscriptions.instance.saveSubscription(this);
if(isOther)
StateSubscriptions.instance.saveSubscriptionOtherAsync(this);
else
StateSubscriptions.instance.saveSubscriptionAsync(this);
}
fun updateChannel(channel: IPlatformChannel) {
@@ -56,7 +56,7 @@ class PolycentricCache {
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system ->
val signedProfileEvents = ApiMethods.getQueryLatest(
val signedEventsList = ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
listOf(
@@ -72,8 +72,9 @@ class PolycentricCache {
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) }
.groupBy { e -> e.event.contentType }
).eventsList.map { e -> SignedEvent.fromProto(e) };
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create()
@@ -151,17 +152,7 @@ class PolycentricCache {
private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope,
{
val urlData = if (it.startsWith("polycentric://")) {
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
throw Exception("Only URLInfoDataLink is supported");
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported");
return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink);
},
{ return@BatchedTaskHandler null },
@@ -325,9 +316,10 @@ class PolycentricCache {
.build();
private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io"
const val STAGING_SERVER = "https://srv1-stg.polycentric.io"
const val SERVER = "https://srv1-prod.polycentric.io"
private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3;
private val CACHE_EXPIRATION_SECONDS = 60 * 5;
@JvmStatic
val instance: PolycentricCache
@@ -343,5 +335,20 @@ class PolycentricCache {
it._scope.cancel("PolycentricCache finished");
}
}
fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? {
val urlData = if (it.startsWith("polycentric://")) {
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
}
}
@@ -143,7 +143,7 @@ class MediaPlaybackService : Service() {
override fun onDestroy() {
Logger.v(TAG, "onDestroy");
_instance = null;
MediaControlReceiver.onCloseReceived.emit();
MediaControlReceiver.onPauseReceived.emit();
super.onDestroy();
}
@@ -153,7 +153,7 @@ class MediaPlaybackService : Service() {
fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession");
stopForeground(STOP_FOREGROUND_DETACH);
stopForeground(STOP_FOREGROUND_REMOVE);
val focusRequest = _focusRequest;
if (focusRequest != null) {
@@ -162,7 +162,9 @@ class MediaPlaybackService : Service() {
}
_hasFocus = false;
_notificationManager?.cancel(MEDIA_NOTIF_ID);
val notifManager = _notificationManager;
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
notifManager?.cancel(MEDIA_NOTIF_ID);
_notif_last_video = null;
_notif_last_bitmap = null;
_mediaSession = null;
@@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.*
import java.io.File
import java.time.OffsetDateTime
@@ -380,13 +382,15 @@ class StateApp {
Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]");
StatePolycentric.instance.load(context);
Logger.i(TAG, "MainApp Starting: Initializing [Saved]");
StateSaved.instance.load();
Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]");
displayMetrics = context.resources.displayMetrics;
ensureConnectivityManager(context);
Logger.i(TAG, "MainApp Starting: Cleaning up unused downloads");
StateDownloads.instance.cleanupDownloads();
Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]");
if (!BuildConfig.DEBUG) {
StateTelemetry.instance.initialize();
@@ -460,7 +464,9 @@ class StateApp {
//Foreground download
autoUpdateEnabled -> {
StateUpdate.instance.checkForUpdates(context, false);
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
}
else -> {
@@ -558,6 +564,40 @@ class StateApp {
if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlatform.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailable.isNotEmpty()) {
UIDialogs.appToast(
ToastView.Toast(updateAvailable
.map { " - " + it.name }
.joinToString("\n"),
true,
null,
"Plugin updates available"
));
StateAnnouncement.instance.registerAnnouncement(
"plugin-update",
"Plugin updates available",
"There are ${updateAvailable.size} plugin updates available.",
AnnouncementType.SESSION_RECURRING
)
}
}
}
/*
UIDialogs.appToast("This is a test", false);
UIDialogs.appToast("This is a test 2", false);
UIDialogs.appToastError("This is a test 3 (Error)", false);
UIDialogs.appToast(ToastView.Toast("This is a test 4, with title", false, Color.WHITE, "Test title"));
UIDialogs.appToast("This is a test 5 Long text\nWith enters\nasdh asfh fds h rwe h fxh sdfh sdf h dsfh sdf hasdfhsdhg ads as", true);
*/
}
fun mainAppStartedWithExternalFiles(context: Context) {
@@ -49,13 +49,17 @@ class StateCache {
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
val subs = StateSubscriptions.instance.getSubscriptions();
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
val allUrls = subs.map {
val allUrls = subs
.map {
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
if(!otherUrls.contains(it.channel.url))
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
else
return@map otherUrls;
}.flatten().distinct();
}
.flatten()
.distinct()
.filter { StatePlatform.instance.hasEnabledChannelClient(it) };
Logger.i(TAG, "Subscriptions CachePager get pagers");
val pagers: List<IPager<IPlatformContent>>;
@@ -352,7 +352,10 @@ class StateDownloads {
fun cleanupDownloads(): Pair<Int, Long> {
val expected = getDownloadedVideos();
val validFiles = HashSet(expected.flatMap { e -> e.videoSource.map { it.filePath } + e.audioSource.map { it.filePath } });
val validFiles = HashSet(expected.flatMap { e ->
e.videoSource.map { it.filePath } +
e.audioSource.map { it.filePath } +
e.subtitlesSources.map { it.filePath }});
var totalDeleted: Long = 0;
var totalDeletedCount = 0;
@@ -5,6 +5,7 @@ import androidx.collection.LruCache
import com.futo.platformplayer.R
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.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -78,6 +79,7 @@ class StatePlatform {
private val _clientsLock = Object();
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
//ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video
@@ -932,6 +934,67 @@ class StatePlatform {
}
}
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
suspend fun checkForUpdates(): List<SourcePluginConfig> = withContext(Dispatchers.IO) {
var configs = mutableListOf<SourcePluginConfig>()
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in getAvailableClients()) {
if (availableClient !is JSClient) {
continue
}
if (checkForUpdates(availableClient.config)) {
configs.add(availableClient.config);
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext configs;
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
private suspend fun checkForUpdates(c: SourcePluginConfig): Boolean = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext false;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext false;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext false;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext true;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext false;
}
}
companion object {
private var _instance : StatePlatform? = null;
val instance : StatePlatform
@@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.developer.DeveloperEndpoints
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage
@@ -467,7 +466,6 @@ class StatePlugins {
_plugins.save(descriptor);
}
@Serializable
private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>,
@@ -27,7 +27,20 @@ import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.*
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.SqlLiteDbHelper
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -38,7 +51,6 @@ import userpackage.Protocol
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import kotlin.Exception
class StatePolycentric {
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
@@ -128,21 +140,21 @@ class StatePolycentric {
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
}
fun hasDisliked(ref: Protocol.Reference): Boolean {
fun hasDisliked(data: ByteArray): Boolean {
if (!enabled) {
return false
}
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasDisliked;
}
fun hasLiked(ref: Protocol.Reference): Boolean {
fun hasLiked(data: ByteArray): Boolean {
if (!enabled) {
return false
}
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasLiked;
}
@@ -316,7 +328,7 @@ class StatePolycentric {
return LikesDislikesReplies(likes, dislikes, replyCount)
}
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> {
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
if (!enabled) {
return EmptyPager()
}
@@ -338,7 +350,8 @@ class StatePolycentric {
Protocol.QueryReferencesRequestCountReferences.newBuilder()
.setFromType(ContentType.POST.value)
.build())
.build()
.build(),
extraByteReferences = extraByteReferences
);
val results = mapQueryReferences(contextUrl, response);
@@ -407,7 +420,8 @@ class StatePolycentric {
ContentType.AVATAR.value,
ContentType.USERNAME.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
).eventsList.map { e -> SignedEvent.fromProto(e) }.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { x -> x.event.unixMilliseconds ?: 0 } };
val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value };
val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value };
@@ -1,52 +0,0 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@kotlinx.serialization.Serializable
data class VideoToOpen(val url: String, val timeSeconds: Long);
class StateSaved {
var videoToOpen: VideoToOpen? = null;
private val _videoToOpen = FragmentedStorage.get<StringStorage>("videoToOpen")
fun load() {
val videoToOpenString = _videoToOpen.value;
if (videoToOpenString.isNotEmpty()) {
try {
val v = Serializer.json.decodeFromString<VideoToOpen>(videoToOpenString);
videoToOpen = v;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to load video to open", e)
}
}
Logger.i(TAG, "loaded videoToOpen=$videoToOpen");
}
fun setVideoToOpenNonBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSave(if (v != null) Serializer.json.encodeToString(v) else "");
}
fun setVideoToOpenBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSaveBlocking(if (v != null) Serializer.json.encodeToString(v) else "");
}
companion object {
const val TAG = "StateSaved"
val instance: StateSaved = StateSaved()
}
}
@@ -1,27 +1,19 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.PlatformID
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.IPlatformContent
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.*
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.functional.CentralizedFeed
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.stores.FragmentedStorage
@@ -32,15 +24,10 @@ import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import kotlinx.coroutines.*
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.streams.asSequence
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
/***
* Used to maintain subscriptions
@@ -54,28 +41,26 @@ class StateSubscriptions {
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription =
Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false)));
}).load();
private val _subscriptionOthers = FragmentedStorage.storeJson<Subscription>("subscriptions_others")
.withUnique { it.channel.url }
.load();
private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency());
private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>();
private var _globalSubscriptionsLock = Object();
private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null;
var isGlobalUpdating: Boolean = false
private set;
var globalSubscriptionExceptions: List<Throwable> = listOf()
private set;
private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SMART;
private var _lastGlobalSubscriptionProgress: Int = 0;
private var _lastGlobalSubscriptionTotal: Int = 0;
val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>();
val onGlobalSubscriptionsUpdated = Event0();
val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>();
val onGlobalSubscriptionsException = Event1<List<Throwable>>();
val global: CentralizedFeed = CentralizedFeed();
val feeds: HashMap<String, CentralizedFeed> = hashMapOf();
val onFeedProgress = Event3<String?, Int, Int>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
init {
global.onUpdateProgress.subscribe { progress, total ->
onFeedProgress.emit(null, progress, total);
}
}
fun getOldestUpdateTime(): OffsetDateTime {
val subs = getSubscriptions();
if(subs.size == 0)
@@ -83,75 +68,98 @@ class StateSubscriptions {
else
return subs.minOf { it.lastVideoUpdate };
}
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
fun getFeed(id: String? = null, createIfNew: Boolean = false): CentralizedFeed? {
if(id == null)
return global;
else {
return synchronized(feeds) {
var f = feeds[id];
if(f == null && createIfNew) {
f = CentralizedFeed();
f.onUpdateProgress.subscribe { progress, total ->
onFeedProgress.emit(id, progress, total)
};
feeds[id] = f;
}
return@synchronized f;
}
}
}
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) {
fun getGlobalSubscriptionProgress(id: String? = null): Pair<Int, Int> {
val feed = getFeed(id, false) ?: return Pair(0, 0);
return Pair(feed.lastProgress, feed.lastTotal);
}
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null, group: SubscriptionGroup? = null) {
val feed = getFeed(group?.id, true) ?: return;
Logger.v(TAG, "updateSubscriptionFeed");
scope.launch(Dispatchers.IO) {
synchronized(_globalSubscriptionsLock) {
if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) {
synchronized(feed.lock) {
if (feed.isGlobalUpdating || (onlyIfNull && feed.feed != null)) {
Logger.i(TAG, "Already updating subscriptions or not required")
return@launch;
}
isGlobalUpdating = true;
feed.isGlobalUpdating = true;
}
try {
val subsResult = getSubscriptionsFeedWithExceptions(true, true, scope, { progress, total ->
_lastGlobalSubscriptionProgress = progress;
_lastGlobalSubscriptionTotal = total;
onGlobalSubscriptionsUpdateProgress.emit(progress, total);
feed.lastProgress = progress;
feed.lastTotal = total;
feed.onUpdateProgress.emit(progress, total);
onProgress?.invoke(progress, total);
});
}, null, group);
if (subsResult.second.any()) {
globalSubscriptionExceptions = subsResult.second;
onGlobalSubscriptionsException.emit(subsResult.second);
feed.exceptions = subsResult.second;
feed.onException.emit(subsResult.second);
}
_globalSubscriptionFeed = subsResult.first.asReusable();
synchronized(_globalSubscriptionsLock) {
onGlobalSubscriptionsUpdated.emit();
onGlobalSubscriptionsUpdatedOnce.emit(null);
onGlobalSubscriptionsUpdatedOnce.clear();
feed.feed = subsResult.first.asReusable();
synchronized(feed.lock) {
feed.onUpdated.emit();
feed.onUpdatedOnce.emit(null);
feed.onUpdatedOnce.clear();
}
}
catch (e: Throwable) {
synchronized(_globalSubscriptionsLock) {
onGlobalSubscriptionsUpdatedOnce.emit(e);
onGlobalSubscriptionsUpdatedOnce.clear();
synchronized(feed.lock) {
feed.onUpdatedOnce.emit(e);
feed.onUpdatedOnce.clear();
}
Logger.e(TAG, "Failed to update subscription feed.", e);
}
finally {
isGlobalUpdating = false;
feed.isGlobalUpdating = false;
}
};
}
fun clearSubscriptionFeed() {
synchronized(_globalSubscriptionsLock) {
_globalSubscriptionFeed = null;
fun clearSubscriptionFeed(id: String? = null) {
val feed = getFeed(id) ?: return;
synchronized(feed.lock) {
feed.feed = null;
}
}
private var loadIndex = 0;
suspend fun getGlobalSubscriptionFeed(scope: CoroutineScope, updated: Boolean): IPager<IPlatformContent> {
suspend fun getGlobalSubscriptionFeed(scope: CoroutineScope, updated: Boolean, group: SubscriptionGroup? = null): IPager<IPlatformContent> {
val feed = getFeed(group?.id, true) ?: return EmptyPager();
//Get Subscriptions only if null
updateSubscriptionFeed(scope, !updated);
updateSubscriptionFeed(scope, !updated, null, group);
val evRef = Object();
val result = suspendCoroutine {
synchronized(_globalSubscriptionsLock) {
if (_globalSubscriptionFeed != null && !updated) {
synchronized(feed.lock) {
if (feed.feed != null && !updated) {
Logger.i(TAG, "Subscriptions got feed preloaded");
it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow()));
it.resumeWith(Result.success(feed.feed!!.getWindow()));
} else {
val loadIndex = loadIndex++;
Logger.i(TAG, "[${loadIndex}] Starting await update");
onGlobalSubscriptionsUpdatedOnce.subscribe(evRef) {ex ->
feed.onUpdatedOnce.subscribe(evRef) { ex ->
Logger.i(TAG, "[${loadIndex}] Subscriptions got feed after update");
if(ex != null)
it.resumeWithException(ex);
else if (_globalSubscriptionFeed != null)
it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow()));
else if (feed.feed != null)
it.resumeWith(Result.success(feed.feed!!.getWindow()));
else
it.resumeWithException(IllegalStateException("No subscription pager after change? Illegal null set on global subscriptions"))
}
@@ -176,12 +184,35 @@ class StateSubscriptions {
return _subscriptions.findItem { it.channel.url == url || it.channel.urlAlternatives.contains(url) };
}
}
fun getSubscriptionOther(url: String) : Subscription? {
synchronized(_subscriptionOthers) {
return _subscriptionOthers.findItem { it.isChannel(url)};
}
}
fun getSubscriptionOtherOrCreate(url: String) : Subscription {
synchronized(_subscriptionOthers) {
val sub = getSubscriptionOther(url);
if(sub == null) {
val newSub = Subscription(SerializedChannel(PlatformID.NONE, url, null, null, 0, null, url, mapOf()));
newSub.isOther = true;
_subscriptions.save(newSub);
return newSub;
}
else return sub;
}
}
fun saveSubscription(sub: Subscription) {
_subscriptions.save(sub, false, true);
}
fun saveSubscriptionAsync(sub: Subscription) {
_subscriptions.saveAsync(sub, false, true);
}
fun saveSubscriptionOther(sub: Subscription) {
_subscriptionOthers.save(sub, false, true);
}
fun saveSubscriptionOtherAsync(sub: Subscription) {
_subscriptionOthers.saveAsync(sub, false, true);
}
fun getSubscriptionCount(): Int {
synchronized(_subscriptions) {
return _subscriptions.getItems().size;
@@ -239,12 +270,19 @@ class StateSubscriptions {
}
}
fun getSubscriptionRequestCount(): Map<JSClient, Int> {
fun getSubscriptionRequestCount(subGroup: SubscriptionGroup? = null): Map<JSClient, Int> {
val subs = getSubscriptions();
val emulatedSubs = subGroup?.let {
it.urls.map {url ->
subs.find { it.channel.url == url }
?: getSubscriptionOtherOrCreate(url);
};
} ?: subs;
return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
.countRequests(getSubscriptions().associateWith { StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id, true) });
.countRequests(emulatedSubs.associateWith { StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id, true) });
}
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit)
@@ -253,10 +291,19 @@ class StateSubscriptions {
onProgress?.invoke(progress, total);
}
val subs = getSubscriptions();
val emulatedSubs = subGroup?.let {
it.urls.map {url ->
subs.find { it.channel.url == url }
?: getSubscriptionOtherOrCreate(url);
};
} ?: subs;
val usePolycentric = true;
val lock = Object();
var polycentricBudget: Int = 10;
val subUrls = getSubscriptions().parallelStream().map {
val subUrls = emulatedSubs.parallelStream().map {
if(usePolycentric) {
val result = StatePolycentric.instance.getChannelUrlsWithUpdateResult(it.channel.url, it.channel.id, polycentricBudget <= 0, true);
if(result.first) {
@@ -2,15 +2,15 @@ package com.futo.platformplayer.states
import android.content.Context
import android.os.Build
import android.os.Environment
import com.futo.platformplayer.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
@@ -155,47 +155,45 @@ class StateUpdate {
}
}
fun checkForUpdates(context: Context, showUpToDateToast: Boolean) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) {
try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion);
} catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) {
try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion);
} catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to retrieve version");
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
UIDialogs.toast(context, "Failed to retrieve version");
}
}
};
} catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
}
}
}
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
@@ -55,7 +55,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val clientCacheCount = clientTasks.value.size - clientTaskCount;
val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
}
}
@@ -0,0 +1,456 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PointF
import android.util.AttributeSet
import android.view.View
import java.security.MessageDigest
import kotlin.math.max
import kotlin.math.min
class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs) {
var hashString: String = "default"
set(value) {
field = value
hash = md5(value)
iconGenerator = null
invalidate()
}
private var hash = ByteArray(16)
private var iconGenerator: IconGenerator? = null
private val path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val radius = (width.coerceAtMost(height) / 2).toFloat()
val clipPath = path.apply {
reset()
addCircle(width / 2f, height / 2f, radius, Path.Direction.CW)
}
canvas.clipPath(clipPath)
if (iconGenerator == null) {
iconGenerator = IconGenerator(min(height, width).toFloat(), hash)
}
iconGenerator?.render(canvas)
}
private fun md5(input: String): ByteArray {
val md = MessageDigest.getInstance("MD5")
return md.digest(input.toByteArray(Charsets.UTF_8))
}
interface Shape {
fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint)
}
class CutCorner : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val k = size * 0.42f
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size - k * 2)
lineTo(size - k, size)
lineTo(0f, size)
close()
}
canvas.drawPath(path, paint)
}
}
class SideTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val w = size / 2
val h = size * 0.8f
val path = Path().apply {
moveTo(size - w, 0f)
lineTo(size, h)
lineTo(size - w, h)
close()
}
canvas.drawPath(path, paint)
}
}
class MiddleSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val s = size / 3
canvas.drawRect(s, s, size - s, size - s, paint)
}
}
class CornerSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.1f
val outer = max(1f, size * 0.25f)
canvas.drawRect(outer, outer, size - inner - outer, size - inner - outer, paint)
}
}
class OffCenterCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size * 0.15f
val s = size * 0.5f
canvas.drawCircle(size - s - m, size - s - m, s / 2, paint)
}
}
class NegativeTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.1f
val outer = inner * 4
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
moveTo(outer, outer)
lineTo(size - inner, outer)
lineTo(outer + (size - outer - inner) / 2, size - inner)
close()
}
canvas.drawPath(path, paint)
}
}
class CutSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size * 0.7f)
lineTo(size * 0.4f, size * 0.4f)
lineTo(size * 0.7f, size)
lineTo(0f, size)
close()
}
canvas.drawPath(path, paint)
}
}
class CornerPlusTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val halfSize = size / 2
canvas.drawRect(0f, 0f, size, halfSize, paint)
canvas.drawRect(0f, halfSize, halfSize, size, paint)
val path = Path().apply {
moveTo(halfSize, halfSize)
lineTo(size, halfSize)
lineTo(halfSize, size)
close()
}
canvas.drawPath(path, paint)
}
}
class NegativeSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.14f
val outer = size * 0.35f
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
addRect(outer, outer, size - outer - inner, size - outer - inner, Path.Direction.CCW)
}
canvas.drawPath(path, paint)
}
}
class NegativeCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.12f
val outer = inner * 3
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
addCircle(outer, outer, (size - inner - outer) / 2, Path.Direction.CCW)
}
canvas.drawPath(path, paint)
}
}
class NegativeRhombus : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size * 0.25f
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
moveTo(m, size / 2)
lineTo(size / 2, m)
lineTo(size - m, size / 2)
lineTo(size / 2, size - m)
close()
}
canvas.drawPath(path, paint)
}
}
class ConditionalCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
if (index == 0) {
val m = size * 0.4f
val s = size * 1.2f
canvas.drawCircle(m, m, s / 2, paint)
}
}
}
class HalfTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(size / 2, size / 2)
lineTo(size, size / 2)
lineTo(size / 2, size)
close()
}
canvas.drawPath(path, paint)
}
}
class Triangle(val corner: Int = 0) : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
when (corner) {
0 -> {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(0f, size)
}
1 -> {
moveTo(size, 0f)
lineTo(size, size)
lineTo(0f, size)
}
2 -> {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size)
}
3 -> {
moveTo(0f, 0f)
lineTo(0f, size)
lineTo(size, size)
}
}
close()
}
canvas.drawPath(path, paint)
}
}
class BottomHalfTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(0f, size / 2)
lineTo(size, size / 2)
lineTo(size / 2, size)
close()
}
canvas.drawPath(path, paint)
}
}
class Rhombus : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(size / 2, 0f)
lineTo(size, size / 2)
lineTo(size / 2, size)
lineTo(0f, size / 2)
close()
}
canvas.drawPath(path, paint)
}
}
class Circle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size / 6
canvas.drawCircle(m, m, size / 2 - m, paint)
}
}
class IconGenerator(private val size: Float, private val hash: ByteArray) {
private val digits: ByteArray
private var selectedColors = arrayOf<Paint>()
init {
digits = ByteArray(max(12, hash.size * 2))
var index = 0
for (byte in hash) {
if (index >= digits.size) {
break
}
digits[index] = ((byte.toInt() shr 4) and 0x0f).toByte()
digits[index + 1] = (byte.toInt() and 0x0f).toByte()
index += 2
}
selectColors()
}
private fun selectColors() {
val value = hash.copyOfRange(hash.size - 4, hash.size).fold(0) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF)
} and 0x0FFFFFFF
val colorTheme = ColorTheme(hue = value.toFloat() / 0x0FFFFFFF)
val selectedColorIndices = mutableListOf<Int>()
for (i in 0 until 3) {
val index = (digits[8 + i].toInt() % colorTheme.colors.size)
selectedColorIndices.add(colorTheme.validateIndex(index, selectedColorIndices))
}
selectedColors = selectedColorIndices.map { index ->
Paint().apply {
color = colorTheme.colors[index]
style = Paint.Style.FILL
}
}.toTypedArray()
}
fun renderBitmap(): Bitmap {
val bitmap = Bitmap.createBitmap(size.toInt(), size.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
render(canvas)
return bitmap
}
fun render(canvas: Canvas) {
canvas.drawColor(Color.WHITE)
renderShape(canvas, 0, outerShapes, 2, 3, arrayOf(
PointF(1f, 0f),
PointF(2f, 0f),
PointF(2f, 3f),
PointF(1f, 3f),
PointF(0f, 1f),
PointF(3f, 1f),
PointF(3f, 2f),
PointF(0f, 2f),
))
renderShape(canvas, 1, outerShapes, 4, 5, arrayOf(
PointF(0f, 0f),
PointF(3f, 0f),
PointF(3f, 3f),
PointF(0f, 3f),
))
renderShape(canvas, 2, centerShapes, 1, null, arrayOf(
PointF(1f, 1f),
PointF(2f, 1f),
PointF(2f, 2f),
PointF(1f, 2f),
))
}
private fun renderShape(
canvas: Canvas,
colorIndex: Int,
shapes: Array<Shape>,
index: Int,
rotationIndex: Int?,
positions: Array<PointF>
) {
val cellSize = size / 4
var r = rotationIndex?.let { digits[it].toInt() } ?: 0
val shape = shapes[digits[index].toInt() % shapes.size]
val paint = Paint().apply {
color = selectedColors[colorIndex % selectedColors.size].color
style = Paint.Style.FILL
}
for ((idx, position) in positions.withIndex()) {
canvas.save()
canvas.translate(position.x * cellSize, position.y * cellSize)
canvas.translate(cellSize / 2, cellSize / 2)
canvas.rotate((r % 4) * 90f)
canvas.translate(-cellSize / 2, -cellSize / 2)
shape.draw(canvas, cellSize, idx, paint)
canvas.restore()
r++
}
}
class ColorTheme(val hue: Float, val saturation: Float = 0.5f) {
val colors: List<Int>
init {
colors = listOf(
// Dark gray
grayscaleColor(0f),
// Mid color
hslColor(hue, saturation, colorLightness(0.5f)),
// Light gray
grayscaleColor(1f),
// Light color
hslColor(hue, saturation, colorLightness(1f)),
// Dark color
hslColor(hue, saturation, colorLightness(0f))
)
}
fun validateIndex(index: Int, selected: List<Int>): Int {
return if (isDuplicate(index, listOf(0, 4), selected) || isDuplicate(index, listOf(2, 3), selected)) {
1
} else {
index
}
}
private fun isDuplicate(index: Int, values: List<Int>, selected: List<Int>): Boolean {
if (!values.contains(index)) return false
return values.any { selected.contains(it) }
}
private fun colorLightness(value: Float): Float = lightness(value, 0.4f, 0.8f)
private fun grayscaleLightness(value: Float): Float = lightness(value, 0.3f, 0.9f)
private fun lightness(value: Float, min: Float, max: Float): Float {
val lightness = min + value * (max - min)
return minOf(1f, maxOf(0f, lightness))
}
private fun grayscaleColor(lightness: Float): Int {
return Color.HSVToColor(floatArrayOf(0f, 0f, lightness))
}
private fun hslColor(hue: Float, saturation: Float, lightness: Float): Int {
return Color.HSVToColor(floatArrayOf(hue, saturation, lightness))
}
}
}
companion object {
val centerShapes = arrayOf(
CutCorner(),
SideTriangle(),
MiddleSquare(),
CornerSquare(),
OffCenterCircle(),
NegativeTriangle(),
CutSquare(),
HalfTriangle(),
CornerPlusTriangle(),
CutSquare(),
NegativeCircle(),
HalfTriangle(),
NegativeRhombus(),
ConditionalCircle()
)
val outerShapes = arrayOf(
Triangle(),
BottomHalfTriangle(),
Rhombus(),
Circle(),
)
private const val TAG = "IdenticonView"
}
}
@@ -0,0 +1,91 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
class ToastView : LinearLayout {
private val root: LinearLayout;
private val title: TextView;
private val text: TextView;
init {
inflate(context, R.layout.toast, this);
root = findViewById(R.id.root);
title = findViewById(R.id.title);
text = findViewById(R.id.text);
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
setToast(ToastView.Toast("", false))
root.visibility = GONE;
}
fun hide(animate: Boolean, onFinished: (()->Unit)? = null) {
Logger.i("MainActivity", "Hiding toast");
if(!animate) {
root.visibility = GONE;
alpha = 0f;
onFinished?.invoke();
}
else {
animate()
.alpha(0f)
.setDuration(700)
.translationY(20.dp(context.resources).toFloat())
.withEndAction { root.visibility = GONE; onFinished?.invoke(); }
.start();
}
}
fun show(animate: Boolean) {
Logger.i("MainActivity", "Showing toast");
if(!animate) {
root.visibility = VISIBLE;
alpha = 1f;
}
else {
alpha = 0f;
root.visibility = VISIBLE;
translationY = 20.dp(context.resources).toFloat();
animate()
.alpha(1f)
.setDuration(700)
.translationY(0f)
.start();
}
}
fun setToast(toast: Toast) {
if(toast.title.isNullOrEmpty())
title.isVisible = false;
else {
title.text = toast.title;
title.isVisible = true;
}
text.text = toast.msg;
if(toast.color != null)
text.setTextColor(toast.color);
else
text.setTextColor(Color.WHITE);
}
fun setToastAnimated(toast: Toast) {
hide(true) {
setToast(toast);
show(true);
};
}
class Toast(
val msg: String,
val long: Boolean,
val color: Int? = null,
val title: String? = null
);
}
@@ -9,15 +9,21 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
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.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
@@ -104,7 +110,8 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name;
val date = comment.date;
@@ -161,8 +168,8 @@ class CommentViewHolder : ViewHolder {
_pillRatingLikesDislikes.visibility = View.VISIBLE;
if (comment is PolycentricPlatformComment) {
val hasLiked = StatePolycentric.instance.hasLiked(comment.reference);
val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference);
val hasLiked = StatePolycentric.instance.hasLiked(comment.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference.toByteArray());
_pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked);
} else {
_pillRatingLikesDislikes.setRating(comment.rating);
@@ -126,7 +126,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
_taskGetLiveComment.cancel()
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name;
val date = comment.date;
@@ -168,8 +169,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
if (likesDislikesReplies != null) {
Log.i(TAG, "updateLikesDislikesReplies set")
val hasLiked = StatePolycentric.instance.hasLiked(c.reference);
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference);
val hasLiked = StatePolycentric.instance.hasLiked(c.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference.toByteArray());
_pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
_buttonReplies.setLoading(false)
@@ -5,7 +5,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
@@ -13,6 +12,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _isRememberedDevice: Boolean;
var onRemove = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>();
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
_devices = devices;
@@ -26,6 +26,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
val holder = DeviceViewHolder(view);
holder.setIsRememberedDevice(_isRememberedDevice);
holder.onRemove.subscribe { d -> onRemove.emit(d); };
holder.onConnect.subscribe { d -> onConnect.emit(d); }
return holder;
}
@@ -31,6 +31,7 @@ class DeviceViewHolder : ViewHolder {
private set
var onRemove = Event1<CastingDevice>();
val onConnect = Event1<CastingDevice>();
constructor(view: View) : super(view) {
_imageDevice = view.findViewById(R.id.image_device);
@@ -56,7 +57,7 @@ class DeviceViewHolder : ViewHolder {
val dev = device ?: return@setOnClickListener;
StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev);
updateButton();
onConnect.emit(dev);
};
_buttonRemove.setOnClickListener {
@@ -64,6 +65,10 @@ class DeviceViewHolder : ViewHolder {
onRemove.emit(dev);
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateButton();
}
setIsRememberedDevice(false);
}
@@ -1,42 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.constructs.Event1
class DisabledSourceAdapter : RecyclerView.Adapter<DisabledSourceViewHolder> {
private val _sources: MutableList<IPlatformClient>;
var onClick = Event1<IPlatformClient>();
var onAdd = Event1<IPlatformClient>();
constructor(sources: MutableList<IPlatformClient>) : super() {
_sources = sources;
}
override fun getItemCount() = _sources.size
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DisabledSourceViewHolder {
val holder = DisabledSourceViewHolder(viewGroup);
holder.onAdd.subscribe {
val source = holder.source;
if (source != null) {
onAdd.emit(source);
}
}
holder.onClick.subscribe {
val source = holder.source;
if (source != null) {
onClick.emit(source);
}
};
return holder;
}
override fun onBindViewHolder(viewHolder: DisabledSourceViewHolder, position: Int) {
viewHolder.bind(_sources[position])
}
}
@@ -1,17 +1,15 @@
package com.futo.platformplayer.views.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class DisabledSourceView : LinearLayout {
private val _root: LinearLayout;
@@ -38,7 +36,16 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_extra_light)
}
_buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); };
@@ -1,44 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
class DisabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
private val _textSource: TextView;
private val _textSourceSubtitle: TextView;
private val _buttonAdd: LinearLayout;
var onClick = Event0();
var onAdd = Event0();
var source: IPlatformClient? = null
private set
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_disabled, viewGroup, false)) {
_imageSource = itemView.findViewById(R.id.image_source);
_textSource = itemView.findViewById(R.id.text_source);
_textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle);
_buttonAdd = itemView.findViewById(R.id.button_add);
val root = itemView.findViewById<LinearLayout>(R.id.root);
_buttonAdd.setOnClickListener { onAdd.emit() }
root.setOnClickListener { onClick.emit(); };
}
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client;
}
}
@@ -10,7 +10,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class EnabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
@@ -57,8 +59,18 @@ class EnabledSourceViewHolder : ViewHolder {
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
_textSource.text = client.name
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = itemView.context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_extra_light)
}
source = client
}
}
@@ -7,7 +7,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -18,8 +18,8 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -149,7 +149,8 @@ open class PlaylistView : LinearLayout {
_neopassAnimator?.cancel();
_neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty();
val firstClaim = claims?.ownedClaims?.firstOrNull();
val harborAvailable = firstClaim != null
if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
@@ -160,7 +161,7 @@ open class PlaylistView : LinearLayout {
_imageNeopassChannel?.visibility = View.GONE
}
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate)
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate, firstClaim?.system?.toProto())
}
companion object {
@@ -6,21 +6,18 @@ import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanTimeIndicator
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -107,7 +104,7 @@ class SubscriptionViewHolder : ViewHolder {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
if (profile != null) {
@@ -15,6 +15,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.debug.Stopwatch
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.video.PlayerManager
@@ -46,7 +47,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
return@TaskHandler Pair(viewHolder, contentDetails)
}).success { previewContentDetails(it.first, it.second) }
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) }
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
@@ -334,7 +334,7 @@ open class PreviewVideoView : LinearLayout {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
} else if (_imageChannel != null) {
val dp_28 = 28.dp(context.resources);
@@ -1,6 +1,5 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.graphics.Color
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
@@ -8,12 +7,10 @@ import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
@@ -76,7 +73,7 @@ class CreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyVi
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
if (profile != null) {
@@ -148,7 +145,7 @@ class SelectableCreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_channel?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
if (profile != null) {
@@ -98,7 +98,7 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
if (profile != null) {
@@ -77,7 +77,7 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
if (profile != null) {
@@ -3,45 +3,31 @@ package com.futo.platformplayer.views.adapters.viewholders
import android.graphics.Color
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.FrameLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<SubscriptionGroup>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.view_subscription_group_bar, _viewGroup, false)) {
private var _group: SubscriptionGroup? = null;
private val _root: FrameLayout;
private val _image: ShapeableImageView;
private val _textSubGroup: TextView;
val onClick = Event1<SubscriptionGroup>();
val onClickLong = Event1<SubscriptionGroup>();
init {
_root = _view.findViewById(R.id.root);
_image = _view.findViewById(R.id.image);
_textSubGroup = _view.findViewById(R.id.text_sub_group);
val dp6 = 6.dp(_view.resources);
_image.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCorners(CornerFamily.ROUNDED, dp6.toFloat())
.build()
_view.setOnClickListener {
_group?.let {
onClick.emit(it);
@@ -58,9 +44,9 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
override fun bind(value: SubscriptionGroup) {
_group = value;
val img = value.image;
if(img != null)
if(img != null) {
img.setImageView(_image)
else {
} else {
_image.setImageResource(0);
if(value is SubscriptionGroup.Add)
@@ -68,10 +54,11 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
}
_textSubGroup.text = value.name;
if(value is SubscriptionGroup.Selectable && value.selected)
_view.setBackgroundColor(_view.context.resources.getColor(R.color.colorPrimary, null));
else
_view.setBackgroundColor(_view.context.resources.getColor(R.color.transparent, null));
if (value is SubscriptionGroup.Selectable && value.selected) {
_root.setBackgroundResource(R.drawable.background_primary_round_6dp)
} else {
_root.background = null
}
}
companion object {
@@ -18,12 +18,20 @@ import android.widget.TextView
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import androidx.core.view.GestureDetectorCompat
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.others.CircularProgressBar
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class GestureControlView : LinearLayout {
private val _scope = CoroutineScope(Dispatchers.Main);
@@ -95,22 +103,23 @@ class GestureControlView : LinearLayout {
if(p0 == null)
return false;
val minDistance = Math.min(width, height)
if (_isFullScreen && _adjustingBrightness) {
val adjustAmount = (distanceY * 2) / height;
val adjustAmount = (distanceY * 2) / minDistance;
_brightnessFactor = (_brightnessFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_progressBrightness.progress = _brightnessFactor;
onBrightnessAdjusted.emit(_brightnessFactor);
} else if (_isFullScreen && _adjustingSound) {
val adjustAmount = (distanceY * 2) / height;
val adjustAmount = (distanceY * 2) / minDistance;
_soundFactor = (_soundFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_progressSound.progress = _soundFactor;
onSoundAdjusted.emit(_soundFactor);
} else if (_adjustingFullscreenUp) {
val adjustAmount = (distanceY * 2) / height;
val adjustAmount = (distanceY * 2) / minDistance;
_fullScreenFactorUp = (_fullScreenFactorUp + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorUp;
} else if (_adjustingFullscreenDown) {
val adjustAmount = (-distanceY * 2) / height;
val adjustAmount = (-distanceY * 2) / minDistance;
_fullScreenFactorDown = (_fullScreenFactorDown + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorDown;
} else {
@@ -18,10 +18,12 @@ import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.behavior.GestureControlView
@@ -51,7 +53,10 @@ class CastView : ConstraintLayout {
private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main);
private var _updateTimeJob: Job? = null;
private var _inPictureInPicture: Boolean = false;
private var _chapters: List<IChapter>? = null;
private var _currentChapter: IChapter? = null;
val onChapterChanged = Event2<IChapter?, Boolean>();
val onMinimizeClick = Event0();
val onSettingsClick = Event0();
val onPrevious = Event0();
@@ -129,6 +134,36 @@ class CastView : ConstraintLayout {
_buttonNext.setOnClickListener { onNext.emit() };
}
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
val currentChapter = getCurrentChapter(chaptPos);
if(_currentChapter != currentChapter) {
_currentChapter = currentChapter;
/*runBlocking(Dispatchers.Main) {
if (currentChapter != null) {
//TODO: Add chapter controls
//_control_chapter.text = " • " + currentChapter.name;
//_control_chapter_fullscreen.text = " • " + currentChapter.name;
} else {
//TODO: Add chapter controls
//_control_chapter.text = "";
//_control_chapter_fullscreen.text = "";
}
}*/
onChapterChanged.emit(currentChapter, isScrub);
return true;
}
return false;
}
fun setChapters(chapters: List<IChapter>?) {
_chapters = chapters;
}
fun getCurrentChapter(pos: Long): IChapter? {
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd } };
}
private fun updateNextPrevious() {
val vidPrev = StatePlayer.instance.getPrevQueueItem(true);
val vidNext = StatePlayer.instance.getNextQueueItem(true);
@@ -225,6 +260,7 @@ class CastView : ConstraintLayout {
@OptIn(UnstableApi::class)
fun setTime(ms: Long) {
updateCurrentChapter(ms);
_textPosition.text = ms.toHumanTime(true);
_timeBar.setPosition(ms / 1000);
StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), ms);
@@ -71,6 +71,10 @@ class FieldForm : LinearLayout {
}
}
fun setSearchQuery(query: String) {
_editSearch.setText(query);
updateSettingsVisibility();
}
fun setSearchVisible(visible: Boolean) {
_containerSearch.visibility = if(visible) View.VISIBLE else View.GONE;
_editSearch.setText("");
@@ -12,12 +12,16 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.views.IdenticonView
import userpackage.Protocol
class CreatorThumbnail : ConstraintLayout {
private val _root: ConstraintLayout;
private val _imageChannelThumbnail: ImageView;
private val _imageNewActivity: ImageView;
private val _imageNeoPass: ImageView;
private val _identicon: IdenticonView;
private var _harborAnimator: ObjectAnimator? = null;
private var _imageAnimator: ObjectAnimator? = null;
@@ -28,19 +32,23 @@ class CreatorThumbnail : ConstraintLayout {
_root = findViewById(R.id.root);
_imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail);
_identicon = findViewById(R.id.identicon);
_imageChannelThumbnail.clipToOutline = true;
_identicon.clipToOutline = true;
_imageChannelThumbnail.visibility = View.GONE
_imageNewActivity = findViewById(R.id.image_new_activity);
_imageNeoPass = findViewById(R.id.image_neopass);
if (!isInEditMode) {
setHarborAvailable(false, animate = false);
setHarborAvailable(false, animate = false, system = null);
setNewActivity(false);
}
}
fun clear() {
_imageChannelThumbnail.visibility = View.GONE;
_imageChannelThumbnail.setImageResource(R.drawable.placeholder_channel_thumbnail);
setHarborAvailable(false, animate = false);
setHarborAvailable(false, animate = false, system = null);
setNewActivity(false);
}
@@ -50,13 +58,24 @@ class CreatorThumbnail : ConstraintLayout {
return;
}
_imageChannelThumbnail.visibility = View.VISIBLE;
_harborAnimator?.cancel();
_harborAnimator = null;
_imageAnimator?.cancel();
_imageAnimator = null;
setHarborAvailable(url.startsWith("polycentric://"), animate);
if (url.startsWith("polycentric://")) {
try {
val dataLink = PolycentricCache.getDataLinkFromUrl(url)
setHarborAvailable(true, animate, dataLink?.system);
} catch (e: Throwable) {
setHarborAvailable(false, animate, null);
}
} else {
setHarborAvailable(false, animate, null);
}
if (animate) {
Glide.with(_imageChannelThumbnail)
@@ -72,7 +91,7 @@ class CreatorThumbnail : ConstraintLayout {
}
}
fun setHarborAvailable(available: Boolean, animate: Boolean) {
fun setHarborAvailable(available: Boolean, animate: Boolean, system: Protocol.PublicKey?) {
_harborAnimator?.cancel();
_harborAnimator = null;
@@ -85,6 +104,13 @@ class CreatorThumbnail : ConstraintLayout {
} else {
_imageNeoPass.visibility = View.GONE;
}
if (system != null) {
_identicon.hashString = system.toString()
_identicon.visibility = View.VISIBLE
} else {
_identicon.visibility = View.GONE
}
}
fun setChannelImageResource(resource: Int?, animate: Boolean) {
@@ -0,0 +1,125 @@
package com.futo.platformplayer.views.overlays
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.viewholders.SelectableCreatorBarViewHolder
class CreatorSelectOverlay: ConstraintLayout {
private val _buttonSelect: FrameLayout;
private val _topbar: OverlayTopbar;
private val _searchBar: SearchView;
private val _recyclerCreators: AnyAdapterView<SelectableCreatorBarViewHolder.Selectable, SelectableCreatorBarViewHolder>;
private val _creators: ArrayList<SelectableCreatorBarViewHolder.Selectable> = arrayListOf();
private val _creatorsFiltered: ArrayList<SelectableCreatorBarViewHolder.Selectable> = arrayListOf();
private var _selected: MutableList<String> = mutableListOf();
val onSelected = Event1<List<String>>();
val onClose = Event0();
constructor(context: Context, hideSubscriptions: List<String>? = null): super(context) {
val subs = StateSubscriptions.instance.getSubscriptions();
if(hideSubscriptions != null) {
_creators.addAll(subs
.filter { !hideSubscriptions.contains(it.channel.url) }
.map { SelectableCreatorBarViewHolder.Selectable(it.channel, false) });
}
else
_creators.addAll(subs
.map { SelectableCreatorBarViewHolder.Selectable(it.channel, false) });
filterCreators();
}
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) { }
init {
inflate(context, R.layout.overlay_creator_select, this);
_topbar = findViewById(R.id.topbar);
_buttonSelect = findViewById(R.id.button_select);
val dp6 = 6.dp(resources);
_searchBar = findViewById(R.id.search_bar);
_recyclerCreators = findViewById<RecyclerView>(R.id.recycler_creators).asAny(_creatorsFiltered, RecyclerView.HORIZONTAL) { creatorView ->
creatorView.itemView.setPadding(0, dp6, 0, dp6);
creatorView.onClick.subscribe {
if(it.channel.thumbnail == null) {
UIDialogs.toast(context, "No thumbnail found");
return@subscribe;
}
if(_selected.contains(it.channel.url))
_selected.remove(it.channel.url);
else
_selected.add(it.channel.url);
updateSelected();
};
};
_recyclerCreators.view.layoutManager = GridLayoutManager(context, 5).apply {
this.orientation = LinearLayoutManager.VERTICAL;
};
_buttonSelect.setOnClickListener {
if (_selected.isNotEmpty()) {
select();
}
};
_topbar.onClose.subscribe {
onClose.emit();
}
_searchBar.onSearchChanged.subscribe {
filterCreators();
};
updateSelected();
filterCreators();
}
fun updateSelected() {
val changed = arrayListOf<SelectableCreatorBarViewHolder.Selectable>()
for(creator in _creators) {
val act = _selected.contains(creator.channel.url);
if(creator.active != act) {
creator.active = act;
changed.add(creator);
}
}
for(change in changed) {
val index = _creatorsFiltered.indexOf(change);
_recyclerCreators.notifyContentChanged(index);
}
if(_selected.isNotEmpty())
_buttonSelect.alpha = 1f;
else
_buttonSelect.alpha = 0.5f;
}
private fun filterCreators(withUpdate: Boolean = true) {
val query = _searchBar.textSearch.text.toString().lowercase();
val filteredEnabled = _creators.filter { query.isEmpty() || it.channel.name.lowercase().contains(query) };
//Optimize
_creatorsFiltered.clear();
_creatorsFiltered.addAll(filteredEnabled);
if(withUpdate)
_recyclerCreators.notifyContentChanged();
}
fun select() {
if(_creators.isEmpty())
return;
onSelected.emit(_selected.toList());
onClose.emit();
}
}
@@ -11,6 +11,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.net.toFile
@@ -48,7 +49,7 @@ class ImageVariableOverlay: ConstraintLayout {
private val _buttonGallery: BigButton;
private val _imageGallerySelected: ImageView;
private val _imageGallerySelectedContainer: LinearLayout;
private val _buttonSelect: Button;
private val _buttonSelect: TextView;
private val _topbar: OverlayTopbar;
private val _recyclerPresets: AnyAdapterView<PresetImage, PresetViewHolder>;
private val _recyclerCreators: AnyAdapterView<SelectableCreatorBarViewHolder.Selectable, SelectableCreatorBarViewHolder>;
@@ -3,6 +3,7 @@ package com.futo.platformplayer.views.overlays
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
@@ -16,6 +17,21 @@ class LoaderOverlay(context: Context, attrs: AttributeSet?) : FrameLayout(contex
inflate(context, R.layout.overlay_loader, this);
_container = findViewById(R.id.container);
_loader = findViewById(R.id.loader);
val centerLoader: Boolean;
if (attrs != null) {
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderOverlay, 0, 0);
centerLoader = attrArr.getBoolean(R.styleable.LoaderOverlay_centerLoader, false);
attrArr.recycle();
} else {
centerLoader = false;
}
if (centerLoader) {
(_loader.layoutParams as LayoutParams).apply {
gravity = Gravity.CENTER
}
}
}
fun show() {
@@ -3,10 +3,13 @@ package com.futo.platformplayer.views.overlays
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.marginRight
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.views.lists.VideoListEditorView
class OverlayTopbar : ConstraintLayout {
@@ -16,6 +19,8 @@ class OverlayTopbar : ConstraintLayout {
private val _button_close: ImageView;
private val _button_list: LinearLayout;
val onClose = Event0();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -24,6 +29,7 @@ class OverlayTopbar : ConstraintLayout {
_name = findViewById(R.id.text_name);
_meta = findViewById(R.id.text_meta);
_button_close = findViewById(R.id.button_close);
_button_list = findViewById(R.id.button_list);
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.OverlayTopbar, 0, 0);
val attrText = attrArr.getText(R.styleable.OverlayTopbar_title) ?: "";
@@ -42,4 +48,20 @@ class OverlayTopbar : ConstraintLayout {
_name.text = name;
_meta.text = meta;
}
fun setButtons(vararg buttons: Pair<Int, ()->Unit>) {
_button_list.removeAllViews();
val dp40 = 40.dp(resources);
val dp5 = 5.dp(resources);
for(button in buttons) {
_button_list.addView(ImageView(context).apply {
layoutParams = LinearLayout.LayoutParams(dp40, dp40)
setPadding(dp5, dp5, dp5 * 2, dp5);
setImageResource(button.first);
setOnClickListener {
button.second();
}
});
}
}
}
@@ -6,8 +6,8 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.structures.IPager
@@ -102,7 +102,8 @@ class RepliesOverlay : LinearLayout {
}
_creatorThumbnail.setThumbnail(parentComment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(parentComment is PolycentricPlatformComment,false);
val polycentricPlatformComment = if (parentComment is PolycentricPlatformComment) parentComment else null
_creatorThumbnail.setHarborAvailable(polycentricPlatformComment != null,false, polycentricPlatformComment?.eventPointer?.system?.toProto());
}
_topbar.setInfo(context.getString(R.string.Replies), metadata);
@@ -0,0 +1,57 @@
package com.futo.platformplayer.views.pills
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
class WidePillButton : LinearLayout {
private val _iconPrefix: ImageView
private val _iconSuffix: ImageView
private val _text: TextView
val onClick = Event0()
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_wide_pill_button, this, true)
_iconPrefix = findViewById(R.id.image_prefix)
_iconSuffix = findViewById(R.id.image_suffix)
_text = findViewById(R.id.text)
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.WidePillButton, 0, 0)
setIconPrefix(attrArr.getResourceId(R.styleable.WidePillButton_widePillIconPrefix, -1))
setIconSuffix(attrArr.getResourceId(R.styleable.WidePillButton_widePillIconSuffix, -1))
setText(attrArr.getText(R.styleable.PillButton_pillText) ?: "")
attrArr.recycle()
findViewById<LinearLayout>(R.id.root).setOnClickListener {
onClick.emit()
}
}
fun setIconPrefix(drawable: Int) {
if (drawable != -1) {
_iconPrefix.setImageResource(drawable)
_iconPrefix.visibility = View.VISIBLE
} else {
_iconPrefix.visibility = View.GONE
}
}
fun setIconSuffix(drawable: Int) {
if (drawable != -1) {
_iconSuffix.setImageResource(drawable)
_iconSuffix.visibility = View.VISIBLE
} else {
_iconSuffix.visibility = View.GONE
}
}
fun setText(t: CharSequence) {
_text.text = t
}
}
@@ -25,6 +25,7 @@ class SourceHeaderView : LinearLayout {
private val _sourcePlatformUrl: TextView;
private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView;
private val _sourceScriptConfig: TextView;
private val _sourceSignature: TextView;
private val _sourcePlatformUrlContainer: LinearLayout;
@@ -45,6 +46,7 @@ class SourceHeaderView : LinearLayout {
_sourcePlatformUrl = findViewById(R.id.source_platform);
_sourcePlatformUrlContainer = findViewById(R.id.source_platform_container);
_sourceScriptUrl = findViewById(R.id.source_script);
_sourceScriptConfig = findViewById(R.id.source_config);
_sourceSignature = findViewById(R.id.source_signature);
_sourceBy.setOnClickListener {
@@ -59,6 +61,10 @@ class SourceHeaderView : LinearLayout {
if(!_config?.absoluteScriptUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl)));
};
_sourceScriptConfig.setOnClickListener {
if(!_config?.sourceUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.sourceUrl)));
}
_sourcePlatformUrl.setOnClickListener {
if(!_config?.platformUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.platformUrl)));
@@ -82,6 +88,7 @@ class SourceHeaderView : LinearLayout {
_sourceVersion.text = config.version.toString();
_sourceScriptUrl.text = config.absoluteScriptUrl;
_sourceRepositoryUrl.text = config.repositoryUrl;
_sourceScriptConfig.text = config.sourceUrl
_sourceAuthorID.text = "";
_sourcePlatformUrl.text = config.platformUrl ?: "";
@@ -2,12 +2,14 @@ package com.futo.platformplayer.views.subscriptions
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.Subscription
@@ -24,7 +26,8 @@ import kotlinx.coroutines.launch
class SubscriptionBar : LinearLayout {
private var _adapterView: AnyAdapterView<Subscription, SubscriptionBarViewHolder>? = null;
private var _subGroups: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>
private var _subGroups: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>;
private var _subGroupsExplore: SubscriptionExploreButton;
private val _tagsContainer: LinearLayout;
private val _groups: ArrayList<SubscriptionGroup>;
@@ -64,7 +67,32 @@ class SubscriptionBar : LinearLayout {
onHoldGroup.emit(g);
}
}
_subGroupsExplore = findViewById(R.id.subgroup_explore);
_tagsContainer = findViewById(R.id.container_tags);
_subGroupsExplore.onClick.subscribe {
UIDialogs.showDialog(context, R.drawable.ic_subscriptions, "Subscription Groups",
"Subscription groups are an easy way to navigate your subscriptions.\n\nDefine your own subsets, and in the near future share them with others.", null, 0,
UIDialogs.Action("Hide Bar", {
Settings.instance.subscriptions.showSubscriptionGroups = false;
Settings.instance.save();
reloadGroups();
UIDialogs.showDialogOk(context, R.drawable.ic_quiz, "Subscription groups can be re-enabled in settings")
}),
UIDialogs.Action("Create", {
onToggleGroup.emit(SubscriptionGroup.Add()); //Shortcut..
}, UIDialogs.ActionStyle.PRIMARY))
};
updateExplore();
}
fun selectGroup(group: SubscriptionGroup) {
val relevantGroup = _groups.find { it.id == group.id };
if(relevantGroup != null && _group != relevantGroup) {
groupClicked(relevantGroup);
}
}
private fun groupClicked(g: SubscriptionGroup) {
@@ -100,6 +128,8 @@ class SubscriptionBar : LinearLayout {
_groups.clear();
_groups.addAll(results);
_subGroups.notifyContentChanged();
updateExplore();
}
private fun getGroups(): List<SubscriptionGroup> {
return if(Settings.instance.subscriptions.showSubscriptionGroups)
@@ -110,6 +140,18 @@ class SubscriptionBar : LinearLayout {
else listOf();
}
fun updateExplore() {
val show = Settings.instance.subscriptions.showSubscriptionGroups &&
_groups.all { it is SubscriptionGroup.Add };
if(show) {
_subGroupsExplore.visibility = View.VISIBLE;
_subGroups.view.visibility = View.GONE;
}
else {
_subGroupsExplore.visibility = View.GONE;
_subGroups.view.visibility = View.VISIBLE;
}
}
fun setToggles(vararg buttons: Toggle) {
_tagsContainer.removeAllViews();
@@ -0,0 +1,45 @@
package com.futo.platformplayer.views.subscriptions
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
class SubscriptionExploreButton : ConstraintLayout {
val onClick = Event0();
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
inflate(context, R.layout.view_subscription_group_bar_explore, this);
val dp10 = 10.dp(resources);
findViewById<ShapeableImageView>(R.id.image)
.apply {
adjustViewBounds = true
scaleType = ImageView.ScaleType.CENTER_CROP;
shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, dp10.toFloat()).build()
}
findViewById<ConstraintLayout>(R.id.root).setOnClickListener {
onClick.emit();
}
}
}
@@ -1,6 +1,9 @@
package com.futo.platformplayer.views.video
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
@@ -11,6 +14,9 @@ import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@@ -39,6 +45,14 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
//Events
private val _evMuteChanged = mutableListOf<(FutoThumbnailPlayer, Boolean)->Unit>();
private val _loadArtwork = object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
setArtwork(null);
}
}
@OptIn(UnstableApi::class)
@@ -113,11 +127,38 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
}
fun setPreview(video: IPlatformVideoDetails) {
val videoSource = VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
val audioSource = VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
setSource(videoSource, audioSource,true, false);
if (video.live != null) {
setSource(video.live, null,true, false);
} else {
val videoSource = VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
val audioSource = VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
if (videoSource == null && audioSource != null) {
val thumbnail = video.thumbnails.getHQThumbnail();
if (!thumbnail.isNullOrBlank()) {
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork);
} else {
Glide.with(videoView).clear(_loadArtwork);
setArtwork(null);
}
} else {
Glide.with(videoView).clear(_loadArtwork);
}
setSource(videoSource, audioSource,true, false);
}
}
override fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean) {
}
@OptIn(UnstableApi::class)
fun setArtwork(drawable: Drawable?) {
if (drawable != null) {
videoView.defaultArtwork = drawable;
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL;
} else {
videoView.defaultArtwork = null;
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF;
}
}
}
@@ -27,6 +27,7 @@ import androidx.media3.ui.TimeBar
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -357,7 +358,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
UIDialogs.showCastingDialog(context);
};
videoControls.setProgressUpdateListener { position, bufferedPosition ->
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
val currentTime = position.formatDuration()
val currentDuration = duration.formatDuration()
_control_time.text = currentTime;
@@ -380,7 +381,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
updateChaptersLoop(++_currentChapterLoopId);
}
}
}
};
_videoControls_fullscreen.setProgressUpdateListener(progressUpdateListener);
videoControls.setProgressUpdateListener(progressUpdateListener);
StatePlayer.instance.onQueueChanged.subscribe(this) {
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
@@ -458,7 +462,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val currentChapter = getCurrentChapter(chaptPos);
if(_currentChapter != currentChapter) {
_currentChapter = currentChapter;
runBlocking(Dispatchers.Main) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
if (currentChapter != null) {
_control_chapter.text = "" + currentChapter.name;
_control_chapter_fullscreen.text = "" + currentChapter.name;
@@ -467,6 +472,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_control_chapter_fullscreen.text = "";
}
onChapterChanged.emit(currentChapter, isScrub);
val chapt = _currentChapter;
if(chapt?.type == ChapterType.SKIPONCE)
ignoreChapter(chapt);
}
return true;
}
@@ -72,6 +72,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private val _referenceObject = Object();
private var _connectivityLossTime_ms: Long? = null
private var _ignoredChapters: ArrayList<IChapter> = arrayListOf();
private var _chapters: List<IChapter>? = null;
var exoPlayer: PlayerManager? = null
@@ -273,13 +274,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
}
fun setChapters(chapters: List<IChapter>?) {
_ignoredChapters = arrayListOf();
_chapters = chapters;
}
fun getChapters(): List<IChapter> {
return _chapters?.let { it.toList() } ?: listOf();
}
fun ignoreChapter(chapter: IChapter) {
synchronized(_ignoredChapters) {
if(!_ignoredChapters.contains(chapter))
_ignoredChapters.add(chapter);
}
}
fun getCurrentChapter(pos: Long): IChapter? {
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd } };
val toIgnore = synchronized(_ignoredChapters){ _ignoredChapters.toList() };
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd && (toIgnore.isEmpty() || !toIgnore.contains(it)) } };
}
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false) {
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:radius="10dp"
android:topRightRadius="10dp"
android:bottomRightRadius="10dp"
android:bottomLeftRadius="10dp" />
<stroke
android:width="1dp"
android:color="#6F6F6F" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:radius="10dp"
android:topRightRadius="10dp"
android:bottomRightRadius="10dp"
android:bottomLeftRadius="10dp" />
<stroke
android:width="1dp"
android:color="#6F6F6F" />
<solid android:color="#99000000" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#99000000" />
<corners android:radius="6dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#2D63ED" />
<corners android:radius="6dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#EE202020" />
<corners android:radius="10dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M481.54,685.39Q494.85,685.39 503.96,676.27Q513.08,667.15 513.08,653.85Q513.08,640.54 503.96,631.42Q494.85,622.31 481.54,622.31Q468.23,622.31 459.11,631.42Q450,640.54 450,653.85Q450,667.15 459.11,676.27Q468.23,685.39 481.54,685.39ZM460.92,552.92L499.54,552.92Q501.08,526.15 509.46,510.31Q517.85,494.46 541.54,470.77Q570.39,441.92 583.35,420.73Q596.31,399.54 596.31,372.61Q596.31,325.77 564.15,297.11Q532,268.46 485.61,268.46Q442.92,268.46 412.11,291.23Q381.31,314 367.08,345.85L403.85,361.08Q413.92,337.15 432.61,321.42Q451.31,305.69 483.15,305.69Q520.92,305.69 539.31,326.35Q557.69,347 557.69,373.31Q557.69,393.39 546.69,410.08Q535.69,426.77 515.85,445.15Q483.61,474.92 472.27,498.73Q460.92,522.54 460.92,552.92ZM224.62,800Q197,800 178.5,781.5Q160,763 160,735.39L160,224.61Q160,197 178.5,178.5Q197,160 224.62,160L735.39,160Q763,160 781.5,178.5Q800,197 800,224.61L800,735.39Q800,763 781.5,781.5Q763,800 735.39,800L224.62,800ZM224.62,760L735.39,760Q744.61,760 752.31,752.31Q760,744.61 760,735.39L760,224.61Q760,215.39 752.31,207.69Q744.61,200 735.39,200L224.62,200Q215.38,200 207.69,207.69Q200,215.39 200,224.61L200,735.39Q200,744.61 207.69,752.31Q215.38,760 224.62,760ZM200,200L200,200Q200,200 200,206.92Q200,213.85 200,224.61L200,735.39Q200,746.15 200,753.08Q200,760 200,760L200,760Q200,760 200,753.08Q200,746.15 200,735.39L200,224.61Q200,213.85 200,206.92Q200,200 200,200Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M481.54,685.39Q494.85,685.39 503.96,676.27Q513.08,667.15 513.08,653.85Q513.08,640.54 503.96,631.42Q494.85,622.31 481.54,622.31Q468.23,622.31 459.11,631.42Q450,640.54 450,653.85Q450,667.15 459.11,676.27Q468.23,685.39 481.54,685.39ZM460.92,552.92L499.54,552.92Q501.08,526.15 509.46,510.31Q517.85,494.46 541.54,470.77Q570.39,441.92 583.35,420.73Q596.31,399.54 596.31,372.61Q596.31,325.77 564.15,297.11Q532,268.46 485.61,268.46Q442.92,268.46 412.11,291.23Q381.31,314 367.08,345.85L403.85,361.08Q413.92,337.15 432.61,321.42Q451.31,305.69 483.15,305.69Q520.92,305.69 539.31,326.35Q557.69,347 557.69,373.31Q557.69,393.39 546.69,410.08Q535.69,426.77 515.85,445.15Q483.61,474.92 472.27,498.73Q460.92,522.54 460.92,552.92ZM224.62,800Q197,800 178.5,781.5Q160,763 160,735.39L160,224.61Q160,197 178.5,178.5Q197,160 224.62,160L735.39,160Q763,160 781.5,178.5Q800,197 800,224.61L800,735.39Q800,763 781.5,781.5Q763,800 735.39,800L224.62,800Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M459.39,744.61L498.61,744.61L498.61,697.69Q541.69,694.08 581.15,666.77Q620.61,639.46 620.61,582Q620.61,540 595.08,510.39Q569.54,480.77 497.54,454.77Q431.39,431.69 413,415.15Q394.61,398.61 394.61,368Q394.61,337.39 418.5,317Q442.39,296.61 482,296.61Q512.46,296.61 532.77,310.58Q553.08,324.54 565.69,346L600.46,332.31Q586.39,303.46 559.19,284Q532,264.54 500.61,262.31L500.61,215.39L461.39,215.39L461.39,262.31Q409.08,271 382.23,301.31Q355.39,331.61 355.39,368Q355.39,411.15 382.5,437.08Q409.61,463 474,486.31Q538.54,510.08 560.73,529.23Q582.92,548.39 582.92,582Q582.92,624.23 552.11,642.81Q521.31,661.39 486,661.39Q451.46,661.39 423.65,641.27Q395.85,621.15 379.23,584L344,599.23Q361.08,640.31 389.81,663.27Q418.54,686.23 459.39,695.69L459.39,744.61ZM480,840Q405.46,840 339.77,811.58Q274.08,783.15 225.46,734.54Q176.85,685.92 148.42,620.23Q120,554.54 120,480Q120,405.46 148.42,339.77Q176.85,274.08 225.46,225.46Q274.08,176.85 339.77,148.42Q405.46,120 480,120Q554.54,120 620.23,148.42Q685.92,176.85 734.54,225.46Q783.15,274.08 811.58,339.77Q840,405.46 840,480Q840,554.54 811.58,620.23Q783.15,685.92 734.54,734.54Q685.92,783.15 620.23,811.58Q554.54,840 480,840Z"/>
</vector>

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