mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-20 15:02:34 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0bc96e53 | |||
| a4422fdd56 | |||
| b7c4047f1d | |||
| 65174ffc97 | |||
| eac3e37af5 | |||
| 0d5ad90ff9 | |||
| f42b14e95a | |||
| b8acd0b5b2 | |||
| ef72561768 | |||
| d63627bd61 | |||
| 422cceb225 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ let Type = {
|
||||
NORMAL: 0,
|
||||
|
||||
SKIPPABLE: 5,
|
||||
SKIP: 6
|
||||
SKIP: 6,
|
||||
SKIPONCE: 7
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
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,6 +19,8 @@ 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
|
||||
@@ -36,7 +40,6 @@ 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
|
||||
@@ -92,6 +95,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;
|
||||
@@ -219,6 +223,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//Main
|
||||
_fragMainHome = HomeFragment.newInstance();
|
||||
_fragMainTutorial = TutorialFragment.newInstance()
|
||||
_fragMainSuggestions = SuggestionsFragment.newInstance();
|
||||
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
|
||||
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
|
||||
@@ -310,6 +315,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 +327,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 +412,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -965,6 +980,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 +1026,33 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
|
||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-3
@@ -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;
|
||||
|
||||
@@ -50,6 +50,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 +272,6 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
@@ -283,152 +284,165 @@ 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) {
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
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(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
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");
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
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 authenticate to Chromecast.", e);
|
||||
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.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() };
|
||||
|
||||
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();
|
||||
//Start ping loop
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
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;
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
Thread.sleep(5000);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.start();
|
||||
|
||||
//Start ping loop
|
||||
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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.start();
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
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 +607,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,41 @@ 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.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 +86,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 +118,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition,
|
||||
@@ -118,7 +142,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition,
|
||||
@@ -134,7 +158,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
setVolume(volume);
|
||||
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
|
||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
@@ -143,7 +167,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
setSpeed(speed);
|
||||
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed))
|
||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
@@ -151,7 +175,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.SEEK, FCastSeekMessage(
|
||||
send(Opcode.Seek, FCastSeekMessage(
|
||||
time = timeSeconds
|
||||
));
|
||||
}
|
||||
@@ -161,7 +185,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.RESUME);
|
||||
send(Opcode.Resume);
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
@@ -169,7 +193,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.PAUSE);
|
||||
send(Opcode.Pause);
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
@@ -177,7 +201,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.STOP);
|
||||
send(Opcode.Stop);
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
@@ -201,7 +225,6 @@ class FCastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
@@ -209,123 +232,133 @@ 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);
|
||||
ensureThreadStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
fun ensureThreadStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
val thread = _thread
|
||||
if (thread == null || !thread.isAlive) {
|
||||
Log.i(TAG, "Restarting thread because the thread has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
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");
|
||||
|
||||
_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(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)
|
||||
}
|
||||
}
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
try {
|
||||
_socket = Socket(usedRemoteAddress, port);
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
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 {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to FastCast.", e);
|
||||
Logger.e(TAG, "Failed to close socket.", 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();
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
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 +372,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 +381,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 +390,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 +400,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 +451,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 +483,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
|
||||
)
|
||||
@@ -1,37 +1,27 @@
|
||||
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.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;
|
||||
@@ -80,6 +70,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) {
|
||||
|
||||
+1
@@ -349,6 +349,7 @@ 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(10, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
|
||||
+3
-3
@@ -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")
|
||||
|
||||
+63
-61
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
@@ -20,6 +21,7 @@ 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
|
||||
@@ -32,7 +34,9 @@ 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
|
||||
@@ -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,16 +86,12 @@ 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: Button;
|
||||
|
||||
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;
|
||||
|
||||
@@ -101,6 +102,7 @@ class SubscriptionGroupFragment : MainFragment() {
|
||||
_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 +112,50 @@ 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);
|
||||
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 +173,14 @@ 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);
|
||||
fragment.close(true);
|
||||
}, UIDialogs.ActionStyle.DANGEROUS))
|
||||
};
|
||||
fragment.close(true);
|
||||
}
|
||||
_buttonSettings.visibility = View.GONE;
|
||||
|
||||
@@ -208,6 +231,28 @@ 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 ->
|
||||
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?) {
|
||||
@@ -230,73 +275,30 @@ class SubscriptionGroupFragment : MainFragment() {
|
||||
@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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-13
@@ -104,14 +104,17 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
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 ->
|
||||
@@ -162,14 +165,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 +197,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 +207,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);
|
||||
|
||||
@@ -262,7 +267,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
UISlideOverlays.showCreateSubscriptionGroup(_overlayContainer);
|
||||
else {
|
||||
_subGroup = g;
|
||||
loadCache(); //TODO: Proper subset update
|
||||
setProgress(0, 0);
|
||||
if(Settings.instance.subscriptions.fetchOnTabOpen)
|
||||
loadResults(false);
|
||||
else loadCache();
|
||||
}
|
||||
};
|
||||
_subscriptionBar?.onHoldGroup?.subscribe { g ->
|
||||
|
||||
+208
@@ -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 = ""
|
||||
override val url: String = ""
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
-11
@@ -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
|
||||
@@ -251,6 +252,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 +329,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,7 +436,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_buttonPins.alwaysShowLastButton = true;
|
||||
|
||||
var buttonMore: RoundButton? = null;
|
||||
buttonMore = RoundButton(context, R.drawable.ic_menu, "More", TAG_MORE) {
|
||||
buttonMore = RoundButton(context, R.drawable.ic_menu, context.getString(R.string.more), TAG_MORE) {
|
||||
_slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected ->
|
||||
_buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray());
|
||||
_buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray())
|
||||
@@ -443,7 +446,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
_buttonMore = buttonMore;
|
||||
updateMoreButtons();
|
||||
|
||||
|
||||
_channelButton.setOnClickListener {
|
||||
(video?.author ?: _searchVideo?.author)?.let {
|
||||
fragment.navigate<ChannelFragment>(it);
|
||||
@@ -459,20 +461,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 +678,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1145,10 +1164,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);
|
||||
@@ -1185,7 +1206,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_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
|
||||
@@ -1372,6 +1398,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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,25 +41,17 @@ 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>();
|
||||
|
||||
@@ -83,75 +62,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 +178,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 +264,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 +285,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer.views.overlays
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.shapes.Shape
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.PresetImages
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
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.dp
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
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.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.CreatorBarViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectableCreatorBarViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import java.io.File
|
||||
|
||||
class CreatorSelectOverlay: ConstraintLayout {
|
||||
private val _buttonSelect: Button;
|
||||
private val _topbar: OverlayTopbar;
|
||||
private val _recyclerCreators: AnyAdapterView<SelectableCreatorBarViewHolder.Selectable, SelectableCreatorBarViewHolder>;
|
||||
|
||||
private val _creators: 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) });
|
||||
_recyclerCreators.notifyContentChanged();
|
||||
}
|
||||
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);
|
||||
_recyclerCreators = findViewById<RecyclerView>(R.id.recycler_creators).asAny(_creators, 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 {
|
||||
_selected?.let {
|
||||
select();
|
||||
}
|
||||
};
|
||||
_topbar.onClose.subscribe {
|
||||
onClose.emit();
|
||||
}
|
||||
updateSelected();
|
||||
}
|
||||
|
||||
fun updateSelected() {
|
||||
_creators.forEach { p -> p.active = _selected.contains(p.channel.url) };
|
||||
_recyclerCreators.notifyContentChanged();
|
||||
|
||||
if(_selected.isNotEmpty())
|
||||
_buttonSelect.alpha = 1f;
|
||||
else
|
||||
_buttonSelect.alpha = 0.5f;
|
||||
}
|
||||
|
||||
|
||||
fun select() {
|
||||
if(_creators.isEmpty())
|
||||
return;
|
||||
onSelected.emit(_selected.toList());
|
||||
onClose.emit();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
android:orientation="vertical"
|
||||
android:animateLayoutChanges="true">
|
||||
<LinearLayout
|
||||
android:id="@+id/container_top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<com.futo.platformplayer.views.overlays.OverlayTopbar
|
||||
android:id="@+id/topbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/black"
|
||||
app:title="Group" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -137,99 +144,63 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="0dp">
|
||||
<LinearLayout
|
||||
android:id="@+id/container_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="10dp">
|
||||
<!--
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/enabled" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/enabled" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:text="@string/these_creators_in_group" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_creators_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_disabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/disabled" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:text="@string/these_creators_not_in_group" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_creators_disabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="10dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:text="@string/these_creators_in_group" /> -->
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/container_top"
|
||||
|
||||
android:orientation="vertical">
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_creators_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/container_top"
|
||||
app:layout_constraintBottom_toTopOf="@id/button_creator_add"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_creator_add"
|
||||
android:layout_width="match_parent"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:layout_height="50dp"
|
||||
android:layout_margin="10dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:text="Add Creator" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:elevation="10dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -488,22 +488,28 @@
|
||||
android:layout_weight="1"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_comment_type"
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_toggle_comment_section"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:text="@string/polycentric"
|
||||
android:layout_marginEnd="8dp" />
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.futo.platformplayer.views.others.Toggle
|
||||
android:id="@+id/toggle_comment_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:toggleEnabled="false"
|
||||
android:layout_marginEnd="14dp" />
|
||||
<TextView
|
||||
android:id="@+id/text_comment_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:text="@string/polycentric"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.futo.platformplayer.views.others.Toggle
|
||||
android:id="@+id/toggle_comment_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:toggleEnabled="false"
|
||||
android:layout_marginEnd="14dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.comments.AddCommentView
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
android:paddingEnd="16dp"
|
||||
android:layout_marginStart="12dp">
|
||||
<TextView
|
||||
android:id="@+id/pill_text"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:background="@color/black"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<com.futo.platformplayer.views.overlays.OverlayTopbar
|
||||
android:id="@+id/topbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
app:title="Select creators"
|
||||
app:metadata=""
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_creators"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/topbar"
|
||||
app:layout_constraintBottom_toTopOf="@id/container_select"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
</androidx.recyclerview.widget.RecyclerView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_select"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
<Button
|
||||
android:id="@+id/button_select"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/colorPrimary"
|
||||
android:text="Select" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -127,7 +127,8 @@
|
||||
android:id="@+id/button_select"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/colorPrimary"
|
||||
android:layout_margin="10dp"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:text="Select" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:id="@+id/root"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_prefix"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_thumb_up" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center_vertical"
|
||||
android:fontFamily="@font/inter_light"
|
||||
tools:text="500K" />
|
||||
|
||||
<Space android:layout_height="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_suffix"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_thumb_up" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -722,6 +722,8 @@
|
||||
<string name="polycentric_is_disabled">Polycentric is disabled</string>
|
||||
<string name="play_pause">Play Pause</string>
|
||||
<string name="position">Position</string>
|
||||
<string name="tutorials">Tutorials</string>
|
||||
<string name="do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button">Do you want to see the tutorials? You can find them at any time through the more button.</string>
|
||||
<string-array name="home_screen_array">
|
||||
<item>Recommendations</item>
|
||||
<item>Subscriptions</item>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="WidePillButton">
|
||||
<attr name="widePillIconPrefix" format="reference" />
|
||||
<attr name="widePilllText" format="string" />
|
||||
<attr name="widePillIconSuffix" format="reference" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
Submodule app/src/unstable/assets/sources/nebula updated: 863d0be132...01270edbb4
Submodule app/src/unstable/assets/sources/youtube updated: d41cc8e848...13551ab67f
Reference in New Issue
Block a user