(R.id.text_explanation).apply {
+ val guideText = """
+ 1. Install FCast Receiver:
+ - Open Play Store, FireStore, or FCast website on your TV/desktop.
+ - Search for "FCast Receiver", install and open it.
+
+
+ 2. Prepare the Grayjay App:
+ - Ensure it's connected to the same network as the FCast Receiver.
+
+
+ 3. Initiate Casting from Grayjay:
+ - Click the cast button in Grayjay.
+
+
+ 4. Connect to FCast Receiver:
+ - Wait for your device to show in the list or add it manually with its IP address.
+
+
+ 5. Confirm Connection:
+ - Click "OK" to confirm your device selection.
+
+
+ 6. Start Casting:
+ - Press "start" next to the device you've added.
+
+
+ 7. Play Your Video:
+ - Start any video in Grayjay to cast.
+
+
+ Finding Your IP Address:
+ On FCast Receiver (Android): Displayed on the main screen.
+ On Windows: Use 'ipconfig' in Command Prompt.
+ On Linux: Use 'hostname -I' or 'ip addr' in Terminal.
+ On MacOS: System Preferences > Network.
+ """.trimIndent()
+
+ text = Html.fromHtml(guideText, Html.FROM_HTML_MODE_COMPACT)
+ }
+
+ findViewById(R.id.button_back).setOnClickListener {
+ UIDialogs.showCastingTutorialDialog(this)
+ finish()
+ }
+
+ findViewById(R.id.button_close).onClick.subscribe {
+ UIDialogs.showCastingTutorialDialog(this)
+ finish()
+ }
+
+ findViewById(R.id.button_website).onClick.subscribe {
+ try {
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
+ startActivity(browserIntent);
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to open browser.", e)
+ }
+ }
+
+ findViewById(R.id.button_technical).onClick.subscribe {
+ try {
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
+ startActivity(browserIntent);
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to open browser.", e)
+ }
+ }
+ }
+
+ override fun onBackPressed() {
+ UIDialogs.showCastingTutorialDialog(this)
+ finish()
+ }
+
+ companion object {
+ private const val TAG = "FCastGuideActivity";
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
index c1d849ef..9f031ed5 100644
--- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
+++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
@@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
-import android.preference.PreferenceManager
import android.util.Log
import android.util.TypedValue
import android.view.View
@@ -25,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
-import com.futo.platformplayer.api.media.PlatformID
-import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
-import com.futo.platformplayer.constructs.Event3
+import com.futo.platformplayer.dialogs.ConnectCastingDialog
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -45,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.google.gson.JsonParser
+import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@@ -90,6 +88,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
lateinit var _fragMainSuggestions: SuggestionsFragment;
lateinit var _fragMainSubscriptions: CreatorsFragment;
+ lateinit var _fragMainComments: CommentsFragment;
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment;
@@ -123,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true;
private var _wasStopped = false;
+ private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
+ scanResult?.let {
+ val content = it.contents
+ if (content == null) {
+ UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
+ return@let
+ }
+
+ try {
+ handleUrlAll(content)
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to handle URL.", e)
+ UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
+ }
+ }
+ }
+
constructor() : super() {
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
val writer = StringWriter();
@@ -205,6 +222,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
_fragMainSubscriptions = CreatorsFragment.newInstance();
+ _fragMainComments = CommentsFragment.newInstance();
_fragMainChannel = ChannelFragment.newInstance();
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
_fragMainSources = SourcesFragment.newInstance();
@@ -282,6 +300,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Set top bars
_fragMainHome.topBar = _fragTopBarGeneral;
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
+ _fragMainComments.topBar = _fragTopBarGeneral;
_fragMainSuggestions.topBar = _fragTopBarSearch;
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
@@ -406,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/
+ fun showUrlQrCodeScanner() {
+ try {
+ val integrator = IntentIntegrator(this)
+ integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
+ integrator.setPrompt(getString(R.string.scan_a_qr_code))
+ integrator.setOrientationLocked(true);
+ integrator.setCameraId(0)
+ integrator.setBeepEnabled(false)
+ integrator.setBarcodeImageEnabled(true)
+ integrator.captureActivity = QRCaptureActivity::class.java
+ _urlQrCodeResultLauncher.launch(integrator.createScanIntent())
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to handle show QR scanner.", e)
+ UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}")
+ }
+ }
+
override fun onResume() {
super.onResume();
Logger.v(TAG, "onResume")
@@ -479,6 +515,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
+ "IMPORT_OPTIONS" -> {
+ UIDialogs.showImportOptionsDialog(this);
+ }
"TAB" -> {
when(intent.getStringExtra("TAB")){
"Sources" -> {
@@ -493,76 +532,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try {
if (targetData != null) {
- when(intent.scheme) {
- "grayjay" -> {
- if(targetData.startsWith("grayjay://license/")) {
- if(StatePayment.instance.setPaymentLicenseUrl(targetData))
- {
- UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
-
- if(fragCurrent is BuyFragment)
- closeSegment(fragCurrent);
- }
- else
- UIDialogs.toast(getString(R.string.invalid_license_format));
-
- }
- else if(targetData.startsWith("grayjay://plugin/")) {
- val intent = Intent(this, AddSourceActivity::class.java).apply {
- data = Uri.parse(targetData.substring("grayjay://plugin/".length));
- };
- startActivity(intent);
- }
- else if(targetData.startsWith("grayjay://video/")) {
- val videoUrl = targetData.substring("grayjay://video/".length);
- navigate(_fragVideoDetail, videoUrl);
- }
- else if(targetData.startsWith("grayjay://channel/")) {
- val channelUrl = targetData.substring("grayjay://channel/".length);
- navigate(_fragMainChannel, channelUrl);
- }
- }
- "content" -> {
- if(!handleContent(targetData, intent.type)) {
- UIDialogs.showSingleButtonDialog(
- this,
- R.drawable.ic_play,
- getString(R.string.unknown_content_format) + " [${targetData}]",
- "Ok",
- { });
- }
- }
- "file" -> {
- if(!handleFile(targetData)) {
- UIDialogs.showSingleButtonDialog(
- this,
- R.drawable.ic_play,
- getString(R.string.unknown_file_format) + " [${targetData}]",
- "Ok",
- { });
- }
- }
- "polycentric" -> {
- if(!handlePolycentric(targetData)) {
- UIDialogs.showSingleButtonDialog(
- this,
- R.drawable.ic_play,
- getString(R.string.unknown_polycentric_format) + " [${targetData}]",
- "Ok",
- { });
- }
- }
- else -> {
- if (!handleUrl(targetData)) {
- UIDialogs.showSingleButtonDialog(
- this,
- R.drawable.ic_play,
- getString(R.string.unknown_url_format) + " [${targetData}]",
- "Ok",
- { });
- }
- }
- }
+ handleUrlAll(targetData)
}
}
catch(ex: Throwable) {
@@ -570,6 +540,90 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
+ fun handleUrlAll(url: String) {
+ val uri = Uri.parse(url)
+ when (uri.scheme) {
+ "grayjay" -> {
+ if(url.startsWith("grayjay://license/")) {
+ if(StatePayment.instance.setPaymentLicenseUrl(url))
+ {
+ UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
+
+ if(fragCurrent is BuyFragment)
+ closeSegment(fragCurrent);
+ }
+ else
+ UIDialogs.toast(getString(R.string.invalid_license_format));
+
+ }
+ else if(url.startsWith("grayjay://plugin/")) {
+ val intent = Intent(this, AddSourceActivity::class.java).apply {
+ data = Uri.parse(url.substring("grayjay://plugin/".length));
+ };
+ startActivity(intent);
+ }
+ else if(url.startsWith("grayjay://video/")) {
+ val videoUrl = url.substring("grayjay://video/".length);
+ navigate(_fragVideoDetail, videoUrl);
+ }
+ else if(url.startsWith("grayjay://channel/")) {
+ val channelUrl = url.substring("grayjay://channel/".length);
+ navigate(_fragMainChannel, channelUrl);
+ }
+ }
+ "content" -> {
+ if(!handleContent(url, intent.type)) {
+ UIDialogs.showSingleButtonDialog(
+ this,
+ R.drawable.ic_play,
+ getString(R.string.unknown_content_format) + " [${url}]",
+ "Ok",
+ { });
+ }
+ }
+ "file" -> {
+ if(!handleFile(url)) {
+ UIDialogs.showSingleButtonDialog(
+ this,
+ R.drawable.ic_play,
+ getString(R.string.unknown_file_format) + " [${url}]",
+ "Ok",
+ { });
+ }
+ }
+ "polycentric" -> {
+ if(!handlePolycentric(url)) {
+ UIDialogs.showSingleButtonDialog(
+ this,
+ R.drawable.ic_play,
+ getString(R.string.unknown_polycentric_format) + " [${url}]",
+ "Ok",
+ { });
+ }
+ }
+ "fcast" -> {
+ if(!handleFCast(url)) {
+ UIDialogs.showSingleButtonDialog(
+ this,
+ R.drawable.ic_cast,
+ "Unknown FCast format [${url}]",
+ "Ok",
+ { });
+ }
+ }
+ else -> {
+ if (!handleUrl(url)) {
+ UIDialogs.showSingleButtonDialog(
+ this,
+ R.drawable.ic_play,
+ getString(R.string.unknown_url_format) + " [${url}]",
+ "Ok",
+ { });
+ }
+ }
+ }
+ }
+
fun handleUrl(url: String): Boolean {
Logger.i(TAG, "handleUrl(url=$url)")
@@ -679,18 +733,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
- val jsonSubs = newPipeSubsParsed["subscriptions"]
- val jsonSubsArray = jsonSubs.asJsonArray;
- val jsonSubsArrayItt = jsonSubsArray.iterator();
- val subs = mutableListOf()
- while(jsonSubsArrayItt.hasNext()) {
- val jsonSubObj = jsonSubsArrayItt.next().asJsonObject;
-
- if(jsonSubObj.has("url"))
- subs.add(jsonSubObj["url"].asString);
- }
-
- navigate(_fragImportSubscriptions, subs);
+ StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
}
catch(ex: Exception) {
Logger.e(TAG, ex.message, ex);
@@ -716,6 +759,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
return true;
}
+
+ fun handleFCast(url: String): Boolean {
+ Logger.i(TAG, "handleFCast");
+
+ try {
+ StateCasting.instance.handleUrl(this, url)
+ return true;
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
+ }
+
+ return false
+ }
+
private fun readSharedContent(contentPath: String): ByteArray {
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
return it.readBytes();
@@ -916,6 +973,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
+ CommentsFragment::class -> _fragMainComments as T;
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
ChannelFragment::class -> _fragMainChannel as T;
@@ -988,5 +1046,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
+
+ fun getImportOptionsIntent(context: Context): Intent {
+ val sourcesIntent = Intent(context, MainActivity::class.java);
+ sourcesIntent.action = "IMPORT_OPTIONS";
+ sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ return sourcesIntent;
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt
index 3e5259a9..e10d855a 100644
--- a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt
+++ b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
+import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
@@ -15,7 +16,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
-import com.futo.platformplayer.views.Loader
+import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
@@ -23,13 +24,15 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
- private lateinit var _loader: Loader;
+ private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
+ lateinit var overlay: FrameLayout;
+
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@@ -43,7 +46,8 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
- _loader = findViewById(R.id.loader);
+ _loaderView = findViewById(R.id.loader);
+ overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, value ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
@@ -70,9 +74,9 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
fun reloadSettings() {
_form.setSearchVisible(false);
- _loader.start();
+ _loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
- _loader.stop();
+ _loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt
index 69c92f49..90a65e00 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt
@@ -4,10 +4,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
-import com.futo.platformplayer.polycentric.PolycentricCache
-import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.Pointer
-import com.futo.polycentric.core.SignedEvent
import userpackage.Protocol.Reference
import java.time.OffsetDateTime
@@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
override val replyCount: Int?;
+ val eventPointer: Pointer;
val reference: Reference;
- constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) {
+ constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
- this.reference = reference;
+ this.eventPointer = eventPointer;
+ this.reference = eventPointer.toReference();
}
override fun getReplies(client: IPlatformClient): IPager {
@@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
}
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
- return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
+ return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
}
companion object {
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt
new file mode 100644
index 00000000..36df5fb2
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt
@@ -0,0 +1,51 @@
+package com.futo.platformplayer.api.media.models.streams.sources
+
+import android.net.Uri
+import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
+
+class HLSVariantVideoUrlSource(
+ override val name: String,
+ override val width: Int,
+ override val height: Int,
+ override val container: String,
+ override val codec: String,
+ override val bitrate: Int?,
+ override val duration: Long,
+ override val priority: Boolean,
+ val url: String
+) : IVideoUrlSource {
+ override fun getVideoUrl(): String {
+ return url
+ }
+}
+
+class HLSVariantAudioUrlSource(
+ override val name: String,
+ override val bitrate: Int,
+ override val container: String,
+ override val codec: String,
+ override val language: String,
+ override val duration: Long?,
+ override val priority: Boolean,
+ val url: String
+) : IAudioUrlSource {
+ override fun getAudioUrl(): String {
+ return url
+ }
+}
+
+class HLSVariantSubtitleUrlSource(
+ override val name: String,
+ override val url: String,
+ override val format: String,
+) : ISubtitleSource {
+ override val hasFetch: Boolean = false
+
+ override fun getSubtitles(): String? {
+ return null
+ }
+
+ override suspend fun getSubtitlesURI(): Uri? {
+ return Uri.parse(url)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt
index e8a8a573..a6748cf2 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient
-import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
@@ -49,7 +48,7 @@ class AirPlayCastingDevice : CastingDevice {
return;
}
- Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
+ Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
if (resumePosition > 0.0) {
diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt
index 66a655be..8beba2f2 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt
@@ -1,10 +1,15 @@
package com.futo.platformplayer.casting
-import android.content.Context
-import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.models.CastingDeviceInfo
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
import java.net.InetAddress
import java.time.OffsetDateTime
@@ -14,10 +19,27 @@ enum class CastConnectionState {
CONNECTED
}
+@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
- FASTCAST
+ FCAST;
+
+ object CastProtocolTypeSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: CastProtocolType) {
+ encoder.encodeString(value.name)
+ }
+
+ override fun deserialize(decoder: Decoder): CastProtocolType {
+ val name = decoder.decodeString()
+ return when (name) {
+ "FASTCAST" -> FCAST // Handle the renamed case
+ else -> CastProtocolType.valueOf(name)
+ }
+ }
+ }
}
abstract class CastingDevice {
diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt
index 39b8c640..eb254b6d 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt
@@ -2,18 +2,16 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
-import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
-import com.futo.platformplayer.protos.DeviceAuthMessageOuterClass
+import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.*
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
-import java.io.IOException
import java.net.InetAddress
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
@@ -376,7 +374,7 @@ class ChromecastCastingDevice : CastingDevice {
//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 = DeviceAuthMessageOuterClass.CastMessage.parseFrom(messageBytes);
+ val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
@@ -429,12 +427,12 @@ class ChromecastCastingDevice : CastingDevice {
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
- val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder()
- .setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0)
+ val castMessage = ChromeCast.CastMessage.newBuilder()
+ .setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
- .setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING)
+ .setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
@@ -448,8 +446,8 @@ class ChromecastCastingDevice : CastingDevice {
}
}
- private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) {
- if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) {
+ private fun handleMessage(message: ChromeCast.CastMessage) {
+ if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
diff --git a/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt
similarity index 95%
rename from app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt
rename to app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt
index da4f8fbf..e3524846 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt
@@ -30,10 +30,10 @@ enum class Opcode(val value: Byte) {
SET_VOLUME(8)
}
-class FastCastCastingDevice : CastingDevice {
+class FCastCastingDevice : CastingDevice {
//See for more info: TODO
- override val protocol: CastProtocolType get() = CastProtocolType.FASTCAST;
+ override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
@@ -72,7 +72,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
- sendMessage(Opcode.PLAY, FastCastPlayMessage(
+ sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition.toInt()
@@ -87,7 +87,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
- sendMessage(Opcode.PLAY, FastCastPlayMessage(
+ sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition.toInt()
@@ -100,7 +100,7 @@ class FastCastCastingDevice : CastingDevice {
}
this.volume = volume
- sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume))
+ sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
}
override fun seekVideo(timeSeconds: Double) {
@@ -108,7 +108,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
- sendMessage(Opcode.SEEK, FastCastSeekMessage(
+ sendMessage(Opcode.SEEK, FCastSeekMessage(
time = timeSeconds.toInt()
));
}
@@ -282,7 +282,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
- val playbackUpdate = Json.decodeFromString(json);
+ val playbackUpdate = Json.decodeFromString(json);
time = playbackUpdate.time.toDouble();
isPlaying = when (playbackUpdate.state) {
1 -> true
@@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
- val volumeUpdate = Json.decodeFromString(json);
+ val volumeUpdate = Json.decodeFromString(json);
volume = volumeUpdate.volume;
}
else -> { }
@@ -398,7 +398,7 @@ class FastCastCastingDevice : CastingDevice {
}
override fun getDeviceInfo(): CastingDeviceInfo {
- return CastingDeviceInfo(name!!, CastProtocolType.FASTCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
+ return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
index b71094b2..f59b55ad 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
@@ -2,8 +2,11 @@ package com.futo.platformplayer.casting
import android.content.ContentResolver
import android.content.Context
+import android.net.Uri
import android.os.Looper
+import android.util.Base64
import com.futo.platformplayer.*
+import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.*
@@ -27,6 +30,9 @@ import javax.jmdns.ServiceListener
import kotlin.collections.HashMap
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
import javax.jmdns.ServiceTypeListener
class StateCasting {
@@ -147,6 +153,32 @@ class StateCasting {
}
}
+ fun handleUrl(context: Context, url: String) {
+ val uri = Uri.parse(url)
+ if (uri.scheme != "fcast") {
+ throw Exception("Expected scheme to be FCast")
+ }
+
+ val type = uri.host
+ if (type != "r") {
+ throw Exception("Expected type r")
+ }
+
+ val connectionInfo = uri.pathSegments[0]
+ val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8)
+ val networkConfig = Json.decodeFromString(json)
+ val tcpService = networkConfig.services.first { v -> v.type == 0 }
+
+ addRememberedDevice(CastingDeviceInfo(
+ name = networkConfig.name,
+ type = CastProtocolType.FCAST,
+ addresses = networkConfig.addresses.toTypedArray(),
+ port = tcpService.port
+ ))
+
+ UIDialogs.toast(context,"FCast device '${networkConfig.name}' added")
+ }
+
fun onStop() {
val ad = activeDevice ?: return;
Logger.i(TAG, "Stopping active device because of onStop.");
@@ -345,7 +377,7 @@ class StateCasting {
} else {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
- if (ad is FastCastCastingDevice) {
+ if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else if (ad is AirPlayCastingDevice) {
@@ -961,7 +993,7 @@ class StateCasting {
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List {
val ad = activeDevice ?: return listOf();
- val proxyStreams = ad !is FastCastCastingDevice;
+ val proxyStreams = ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
@@ -1042,8 +1074,8 @@ class StateCasting {
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
- CastProtocolType.FASTCAST -> {
- FastCastCastingDevice(deviceInfo);
+ CastProtocolType.FCAST -> {
+ FCastCastingDevice(deviceInfo);
}
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
}
@@ -1090,8 +1122,8 @@ class StateCasting {
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) {
- return addOrUpdateCastDevice(name,
- deviceFactory = { FastCastCastingDevice(name, addresses, port) },
+ return addOrUpdateCastDevice(name,
+ deviceFactory = { FCastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
@@ -1167,6 +1199,19 @@ class StateCasting {
}
}
+ @Serializable
+ private data class FCastNetworkConfig(
+ val name: String,
+ val addresses: List,
+ val services: List
+ )
+
+ @Serializable
+ private data class FCastService(
+ val port: Int,
+ val type: Int
+ )
+
companion object {
val instance: StateCasting = StateCasting();
diff --git a/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt
similarity index 71%
rename from app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt
rename to app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt
index 5b8e8272..64de18ba 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt
@@ -3,7 +3,7 @@ package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@kotlinx.serialization.Serializable
-data class FastCastPlayMessage(
+data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
@@ -11,23 +11,23 @@ data class FastCastPlayMessage(
) { }
@kotlinx.serialization.Serializable
-data class FastCastSeekMessage(
+data class FCastSeekMessage(
val time: Int
) { }
@kotlinx.serialization.Serializable
-data class FastCastPlaybackUpdateMessage(
+data class FCastPlaybackUpdateMessage(
val time: Int,
val state: Int
) { }
@Serializable
-data class FastCastVolumeUpdateMessage(
+data class FCastVolumeUpdateMessage(
val volume: Double
)
@Serializable
-data class FastCastSetVolumeMessage(
+data class FCastSetVolumeMessage(
val volume: Double
)
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt
index 9966e40a..295e191a 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt
@@ -12,10 +12,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
-import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toInetAddress
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@@ -26,6 +23,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
private lateinit var _textError: TextView;
private lateinit var _buttonCancel: Button;
private lateinit var _buttonConfirm: LinearLayout;
+ private lateinit var _buttonTutorial: TextView;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -38,6 +36,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError = findViewById(R.id.text_error);
_buttonCancel = findViewById(R.id.button_cancel);
_buttonConfirm = findViewById(R.id.button_confirm);
+ _buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -62,7 +61,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm.setOnClickListener {
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
- 0 -> CastProtocolType.FASTCAST
+ 0 -> CastProtocolType.FCAST
1 -> CastProtocolType.CHROMECAST
2 -> CastProtocolType.AIRPLAY
else -> {
@@ -105,6 +104,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
performDismiss();
};
+
+ _buttonTutorial.setOnClickListener {
+ UIDialogs.showCastingTutorialDialog(context)
+ dismiss()
+ }
}
override fun show() {
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt
new file mode 100644
index 00000000..9f305b18
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt
@@ -0,0 +1,63 @@
+package com.futo.platformplayer.dialogs
+
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import com.futo.platformplayer.R
+import com.futo.platformplayer.UIDialogs
+import com.futo.platformplayer.activities.FCastGuideActivity
+import com.futo.platformplayer.activities.PolycentricWhyActivity
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.views.buttons.BigButton
+
+
+class CastingHelpDialog(context: Context?) : AlertDialog(context) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState);
+ setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_help, null));
+
+ findViewById(R.id.button_guide).onClick.subscribe {
+ context.startActivity(Intent(context, FCastGuideActivity::class.java))
+ }
+
+ findViewById(R.id.button_video).onClick.subscribe {
+ try {
+ //TODO: Replace the URL with the casting video URL
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
+ context.startActivity(browserIntent);
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to open browser.", e)
+ }
+ }
+
+ findViewById(R.id.button_website).onClick.subscribe {
+ try {
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
+ context.startActivity(browserIntent);
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to open browser.", e)
+ }
+ }
+
+ findViewById(R.id.button_technical).onClick.subscribe {
+ try {
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
+ context.startActivity(browserIntent);
+ } catch (e: Throwable) {
+ Logger.i(TAG, "Failed to open browser.", e)
+ }
+ }
+
+ findViewById(R.id.button_close).onClick.subscribe {
+ dismiss()
+ UIDialogs.showCastingAddDialog(context)
+ }
+ }
+
+ companion object {
+ private val TAG = "CastingTutorialDialog";
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt
index 584c8465..cc9015eb 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt
@@ -118,7 +118,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
msg = comment,
rating = RatingLikeDislikes(0, 0),
date = OffsetDateTime.now(),
- reference = eventPointer.toReference()
+ eventPointer = eventPointer
));
dismiss();
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
index dca38091..8f13545c 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
@@ -1,24 +1,33 @@
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.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
@@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button;
+ private lateinit var _buttonScanQR: Button;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
@@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add);
+ _buttonScanQR = findViewById(R.id.button_scan_qr);
_recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
@@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
UIDialogs.showCastingAddDialog(context);
dismiss();
};
+
+ val c = ownerActivity
+ if (c is MainActivity) {
+ _buttonScanQR.visibility = View.VISIBLE
+ _buttonScanQR.setOnClickListener {
+ c.showUrlQrCodeScanner()
+ dismiss()
+ };
+ } else {
+ _buttonScanQR.visibility = View.GONE
+ }
}
override fun show() {
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt
index 612c7a8c..619db900 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt
@@ -16,9 +16,7 @@ import com.futo.platformplayer.casting.*
import com.futo.platformplayer.states.StateApp
import com.google.android.material.slider.Slider
import com.google.android.material.slider.Slider.OnChangeListener
-import com.google.android.material.slider.Slider.OnSliderTouchListener
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
@@ -105,7 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
- } else if (d is FastCastCastingDevice) {
+ } else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
}
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt
new file mode 100644
index 00000000..4abdb16c
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt
@@ -0,0 +1,73 @@
+package com.futo.platformplayer.dialogs
+
+import android.app.AlertDialog
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.widget.Button
+import com.futo.platformplayer.R
+import com.futo.platformplayer.activities.MainActivity
+import com.futo.platformplayer.readBytes
+import com.futo.platformplayer.states.StateApp
+import com.futo.platformplayer.states.StateBackup
+import com.futo.platformplayer.views.buttons.BigButton
+
+class ImportOptionsDialog: AlertDialog {
+ private val _context: MainActivity;
+
+ private lateinit var _button_import_zip: BigButton;
+ private lateinit var _button_import_ezip: BigButton;
+ private lateinit var _button_import_txt: BigButton;
+ private lateinit var _button_import_newpipe_subs: BigButton;
+ private lateinit var _button_close: Button;
+
+
+ constructor(context: MainActivity): super(context) {
+ _context = context;
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState);
+ setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_import_options, null));
+ _button_import_zip = findViewById(R.id.button_import_zip);
+ _button_import_ezip = findViewById(R.id.button_import_ezip);
+ _button_import_txt = findViewById(R.id.button_import_txt);
+ _button_import_newpipe_subs = findViewById(R.id.button_import_newpipe_subs);
+ _button_close = findViewById(R.id.button_cancel);
+
+ _button_import_zip.onClick.subscribe {
+ dismiss();
+ StateApp.instance.requestFileReadAccess(_context, null, "application/zip") {
+ val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
+ StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes);
+ };
+ }
+ _button_import_ezip.setOnClickListener {
+
+ }
+ _button_import_txt.onClick.subscribe {
+ dismiss();
+ StateApp.instance.requestFileReadAccess(_context, null, "text/plain") {
+ val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
+ val txt = String(txtBytes);
+ StateBackup.importTxt(_context, txt);
+ };
+ }
+ _button_import_newpipe_subs.onClick.subscribe {
+ dismiss();
+ StateApp.instance.requestFileReadAccess(_context, null, "application/json") {
+ val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess;
+ val json = String(jsonBytes);
+ StateBackup.importNewPipeSubs(_context, json);
+ };
+ };
+ _button_close.setOnClickListener {
+ dismiss();
+ }
+ }
+
+ override fun dismiss() {
+ super.dismiss();
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
index 7f082407..048e36d3 100644
--- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
+++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
@@ -1,11 +1,17 @@
package com.futo.platformplayer.downloads
+import android.content.Context
+import android.util.Log
+import com.arthenica.ffmpegkit.FFmpegKit
+import com.arthenica.ffmpegkit.ReturnCode
+import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
+import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -18,22 +24,28 @@ import com.futo.platformplayer.hasAnySource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.isDownloadable
+import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
-import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.OffsetDateTime
+import java.util.UUID
+import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
+import kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable
class VideoDownload {
@@ -137,7 +149,7 @@ class VideoDownload {
return items.joinToString(" • ");
}
- suspend fun prepare() {
+ suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]");
if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete");
@@ -157,24 +169,65 @@ class VideoDownload {
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) {
- val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
+ val videoSources = arrayListOf()
+ for (source in original.video.videoSources) {
+ if (source is IHLSManifestSource) {
+ try {
+ val playlistResponse = client.get(source.url)
+ if (playlistResponse.isOk) {
+ val playlistContent = playlistResponse.body?.string()
+ if (playlistContent != null) {
+ videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
+ }
+ }
+ } catch (e: Throwable) {
+ Log.i(TAG, "Failed to get HLS video sources", e)
+ }
+ } else {
+ videoSources.add(source)
+ }
+ }
+
+ val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource != null) {
if (vsource is IVideoUrlSource)
- videoSource = VideoUrlSource.fromUrlSource(vsource);
+ videoSource = VideoUrlSource.fromUrlSource(vsource)
else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
}
if(audioSource == null && targetBitrate != null) {
- val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
+ val audioSources = arrayListOf()
+ val video = original.video
+ if (video is VideoUnMuxedSourceDescriptor) {
+ for (source in video.audioSources) {
+ if (source is IHLSManifestSource) {
+ try {
+ val playlistResponse = client.get(source.url)
+ if (playlistResponse.isOk) {
+ val playlistContent = playlistResponse.body?.string()
+ if (playlistContent != null) {
+ audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
+ }
+ }
+ } catch (e: Throwable) {
+ Log.i(TAG, "Failed to get HLS audio sources", e)
+ }
+ } else {
+ audioSources.add(source)
+ }
+ }
+ }
+
+ val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null)
audioSource = null;
else if(asource is IAudioUrlSource)
- audioSource = AudioUrlSource.fromUrlSource(asource);
+ audioSource = AudioUrlSource.fromUrlSource(asource)
else
throw DownloadException("Audio source is not supported for downloading (yet)", false);
}
@@ -183,7 +236,8 @@ class VideoDownload {
throw DownloadException("No valid sources found for video/audio");
}
}
- suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
+
+ suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
Logger.i(TAG, "VideoDownload Download [${name}]");
if(videoDetails == null || (videoSource == null && audioSource == null))
throw IllegalStateException("Missing information for download to complete");
@@ -199,7 +253,7 @@ class VideoDownload {
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
}
if(audioSource != null) {
- audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
+ audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
if(subtitleSource != null) {
@@ -217,7 +271,8 @@ class VideoDownload {
if(videoSource != null) {
sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading video");
- videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed ->
+
+ val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) {
lastVideoLength = length;
lastVideoRead = totalRead;
@@ -235,12 +290,18 @@ class VideoDownload {
}
}
}
+
+ videoFileSize = when (videoSource!!.container) {
+ "application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
+ else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
+ }
});
}
if(audioSource != null) {
sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading audio");
- audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed ->
+
+ val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) {
lastAudioLength = length;
lastAudioRead = totalRead;
@@ -258,6 +319,11 @@ class VideoDownload {
}
}
}
+
+ audioFileSize = when (audioSource!!.container) {
+ "application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
+ else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
+ }
});
}
if (subtitleSource != null) {
@@ -279,7 +345,105 @@ class VideoDownload {
throw ex;
}
}
- private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
+
+ private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
+ if(targetFile.exists())
+ targetFile.delete();
+
+ var downloadedTotalLength = 0L
+
+ val segmentFiles = arrayListOf()
+ try {
+ val response = client.get(hlsUrl)
+ check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
+
+ val vpContent = response.body?.string()
+ ?: throw Exception("Variant playlist content is empty")
+
+ val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
+ variantPlaylist.segments.forEachIndexed { index, segment ->
+ if (segment !is HLS.MediaSegment) {
+ return@forEachIndexed
+ }
+
+ Logger.i(TAG, "Download '$name' segment $index Sequential");
+ val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
+ segmentFiles.add(segmentFile)
+
+ val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
+ val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
+ val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
+ onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
+ }
+
+ downloadedTotalLength += segmentLength
+ }
+
+ Logger.i(TAG, "Combining segments into $targetFile");
+ combineSegments(context, segmentFiles, targetFile)
+
+ Logger.i(TAG, "${name} downloadSource Finished");
+ }
+ catch(ioex: IOException) {
+ if(targetFile.exists() ?: false)
+ targetFile.delete();
+ if(ioex.message?.contains("ENOSPC") ?: false)
+ throw Exception("Not enough space on device", ioex);
+ else
+ throw ioex;
+ }
+ catch(ex: Throwable) {
+ if(targetFile.exists() ?: false)
+ targetFile.delete();
+ throw ex;
+ }
+ finally {
+ for (segmentFile in segmentFiles) {
+ segmentFile.delete()
+ }
+ }
+ return downloadedTotalLength;
+ }
+
+ private suspend fun combineSegments(context: Context, segmentFiles: List, targetFile: File) = withContext(Dispatchers.IO) {
+ suspendCancellableCoroutine { continuation ->
+ val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
+ fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
+
+ val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
+
+ val statisticsCallback = StatisticsCallback { statistics ->
+ //TODO: Show progress?
+ }
+
+ val executorService = Executors.newSingleThreadExecutor()
+ val session = FFmpegKit.executeAsync(cmd,
+ { session ->
+ if (ReturnCode.isSuccess(session.returnCode)) {
+ fileList.delete()
+ continuation.resumeWith(Result.success(Unit))
+ } else {
+ val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
+ "Command cancelled"
+ } else {
+ "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
+ }
+ fileList.delete()
+ continuation.resumeWithException(RuntimeException(errorMessage))
+ }
+ },
+ { Logger.v(TAG, it.message) },
+ statisticsCallback,
+ executorService
+ )
+
+ continuation.invokeOnCancellation {
+ session.cancel()
+ }
+ }
+ }
+
+ private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -472,8 +636,10 @@ class VideoDownload {
val expectedFile = File(videoFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Video file missing after download");
- if(expectedFile.length() != videoFileSize)
- throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
+ if (videoSource?.container != "application/vnd.apple.mpegurl") {
+ if (expectedFile.length() != videoFileSize)
+ throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
+ }
}
if(audioSource != null) {
if(audioFilePath == null)
@@ -481,8 +647,10 @@ class VideoDownload {
val expectedFile = File(audioFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Audio file missing after download");
- if(expectedFile.length() != audioFileSize)
- throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
+ if (audioSource?.container != "application/vnd.apple.mpegurl") {
+ if (expectedFile.length() != audioFileSize)
+ throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
+ }
}
if(subtitleSource != null) {
if(subtitleFilePath == null)
@@ -560,7 +728,7 @@ class VideoDownload {
const val GROUP_PLAYLIST = "Playlist";
fun videoContainerToExtension(container: String): String? {
- if (container.contains("video/mp4"))
+ if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4";
else if (container.contains("application/x-mpegURL"))
return "m3u8";
@@ -585,6 +753,8 @@ class VideoDownload {
return "mp3";
else if (container.contains("audio/webm"))
return "webma";
+ else if (container == "application/vnd.apple.mpegurl")
+ return "mp4";
else
return "audio";
}
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
index 6d7b8991..f2e420b9 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
@@ -351,6 +351,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }),
+ ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate() }),
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()");
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt
index e0d13b28..ca970cfb 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt
@@ -35,6 +35,11 @@ class BuyFragment : MainFragment() {
return view;
}
+ override fun onDestroyMainView() {
+ super.onDestroyMainView()
+ _view = null
+ }
+
class BuyView: LinearLayout {
private val _fragment: BuyFragment;
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt
index 743648ef..a1d1ed9f 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt
@@ -437,11 +437,12 @@ class ChannelFragment : MainFragment() {
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
+ setPolycentricProfile(null, animate = false);
+
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false);
} else {
- setPolycentricProfile(null, animate = false);
or();
}
}
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt
new file mode 100644
index 00000000..c81ac8de
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt
@@ -0,0 +1,322 @@
+package com.futo.platformplayer.fragment.mainactivity.main
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewPropertyAnimator
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.Spinner
+import android.widget.TextView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.futo.platformplayer.R
+import com.futo.platformplayer.UIDialogs
+import com.futo.platformplayer.activities.PolycentricHomeActivity
+import com.futo.platformplayer.api.media.models.comments.IPlatformComment
+import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
+import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
+import com.futo.platformplayer.constructs.TaskHandler
+import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.states.StateApp
+import com.futo.platformplayer.states.StatePlatform
+import com.futo.platformplayer.states.StatePolycentric
+import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
+import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
+import com.futo.platformplayer.views.overlays.RepliesOverlay
+import com.futo.polycentric.core.PublicKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.net.UnknownHostException
+import java.util.IdentityHashMap
+
+class CommentsFragment : MainFragment() {
+ override val isMainView : Boolean = true
+ override val isTab: Boolean = true
+ override val hasBottomBar: Boolean get() = true
+
+ private var _view: CommentsView? = null
+
+ override fun onShownWithView(parameter: Any?, isBack: Boolean) {
+ super.onShownWithView(parameter, isBack)
+ _view?.onShown()
+ }
+
+ override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ val view = CommentsView(this, inflater)
+ _view = view
+ return view
+ }
+
+ override fun onDestroyMainView() {
+ super.onDestroyMainView()
+ _view = null
+ }
+
+ override fun onBackPressed(): Boolean {
+ return _view?.onBackPressed() ?: false
+ }
+
+ override fun onResume() {
+ super.onResume()
+ _view?.onShown()
+ }
+
+ companion object {
+ fun newInstance() = CommentsFragment().apply {}
+ private const val TAG = "CommentsFragment"
+ }
+
+ class CommentsView : FrameLayout {
+ private val _fragment: CommentsFragment
+ private val _recyclerComments: RecyclerView;
+ private val _adapterComments: InsertedViewAdapterWithLoader;
+ private val _textCommentCount: TextView
+ private val _comments: ArrayList = arrayListOf();
+ private val _llmReplies: LinearLayoutManager;
+ private val _spinnerSortBy: Spinner;
+ private val _layoutNotLoggedIn: LinearLayout;
+ private val _layoutPolycentricNotEnabled: LinearLayout;
+ private val _buttonLogin: LinearLayout;
+ private var _loading = false;
+ private val _repliesOverlay: RepliesOverlay;
+ private var _repliesAnimator: ViewPropertyAnimator? = null;
+ private val _cache: IdentityHashMap = IdentityHashMap()
+
+ private val _taskLoadComments = if(!isInEditMode) TaskHandler>(
+ StateApp.instance.scopeGetter, { StatePolycentric.instance.getSystemComments(context, it) })
+ .success { pager -> onCommentsLoaded(pager); }
+ .exception {
+ UIDialogs.toast("Failed to load comments");
+ setLoading(false);
+ }
+ .exception {
+ Logger.e(TAG, "Failed to load comments.", it);
+ UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
+ setLoading(false);
+ } else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
+
+ constructor(fragment: CommentsFragment, inflater: LayoutInflater) : super(inflater.context) {
+ _fragment = fragment
+ inflater.inflate(R.layout.fragment_comments, this)
+
+ val commentHeader = findViewById(R.id.layout_header)
+ (commentHeader.parent as ViewGroup).removeView(commentHeader)
+ _textCommentCount = commentHeader.findViewById(R.id.text_comment_count)
+
+ _recyclerComments = findViewById(R.id.recycler_comments)
+ _adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(commentHeader), arrayListOf(),
+ childCountGetter = { _comments.size },
+ childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position]); },
+ childViewHolderFactory = { viewGroup, _ ->
+ val holder = CommentWithReferenceViewHolder(viewGroup, _cache);
+ holder.onDelete.subscribe(::onDelete);
+ holder.onRepliesClick.subscribe(::onRepliesClick);
+ return@InsertedViewAdapterWithLoader holder;
+ }
+ );
+
+ _spinnerSortBy = commentHeader.findViewById(R.id.spinner_sortby);
+ _spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.comments_sortby_array)).also {
+ it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
+ };
+ _spinnerSortBy.setSelection(0);
+ _spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
+ if (_spinnerSortBy.selectedItemPosition == 0) {
+ _comments.sortByDescending { it.date!! }
+ } else if (_spinnerSortBy.selectedItemPosition == 1) {
+ _comments.sortBy { it.date!! }
+ }
+
+ _adapterComments.notifyDataSetChanged()
+ }
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+ }
+
+ _llmReplies = LinearLayoutManager(context);
+ _recyclerComments.layoutManager = _llmReplies;
+ _recyclerComments.adapter = _adapterComments;
+ updateCommentCountString();
+
+ _layoutNotLoggedIn = findViewById(R.id.layout_not_logged_in)
+ _layoutNotLoggedIn.visibility = View.GONE
+
+ _layoutPolycentricNotEnabled = findViewById(R.id.layout_polycentric_disabled)
+ _layoutPolycentricNotEnabled.visibility = if (!StatePolycentric.instance.enabled) View.VISIBLE else View.GONE
+
+ _buttonLogin = findViewById(R.id.button_login)
+ _buttonLogin.setOnClickListener {
+ context.startActivity(Intent(context, PolycentricHomeActivity::class.java));
+ }
+
+ _repliesOverlay = findViewById(R.id.replies_overlay);
+ _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
+ }
+
+ private fun onDelete(comment: IPlatformComment) {
+ UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete this comment?", {
+ val processHandle = StatePolycentric.instance.processHandle ?: return@showConfirmationDialog
+ if (comment !is PolycentricPlatformComment) {
+ return@showConfirmationDialog
+ }
+
+ val index = _comments.indexOf(comment)
+ if (index != -1) {
+ _comments.removeAt(index)
+ _adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
+
+ StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
+ try {
+ processHandle.delete(comment.eventPointer.process, comment.eventPointer.logicalClock)
+ } catch (e: Throwable) {
+ Logger.e(TAG, "Failed to delete event.", e);
+ return@launch
+ }
+
+ try {
+ Logger.i(TAG, "Started backfill");
+ processHandle.fullyBackfillServersAnnounceExceptions();
+ Logger.i(TAG, "Finished backfill");
+ } catch (e: Throwable) {
+ Logger.e(TAG, "Failed to fully backfill servers.", e);
+ }
+ }
+ }
+ })
+ }
+
+ fun onBackPressed(): Boolean {
+ if (_repliesOverlay.visibility == View.VISIBLE) {
+ setRepliesOverlayVisible(isVisible = false, animate = true);
+ return true
+ }
+
+ return false
+ }
+
+ private fun onRepliesClick(c: IPlatformComment) {
+ val replyCount = c.replyCount ?: 0;
+ var metadata = "";
+ if (replyCount > 0) {
+ metadata += "$replyCount " + context.getString(R.string.replies);
+ }
+
+ if (c is PolycentricPlatformComment) {
+ _repliesOverlay.load(false, metadata, c.contextUrl, c.reference, c,
+ { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
+ { newComment ->
+ synchronized(_cache) {
+ _cache.remove(c)
+ }
+
+ val newCommentIndex = if (_spinnerSortBy.selectedItemPosition == 0) {
+ _comments.indexOfFirst { it.date!! < newComment.date!! }.takeIf { it != -1 } ?: _comments.size
+ } else {
+ _comments.indexOfFirst { it.date!! > newComment.date!! }.takeIf { it != -1 } ?: _comments.size
+ }
+
+ _comments.add(newCommentIndex, newComment)
+ _adapterComments.notifyItemInserted(_adapterComments.childToParentPosition(newCommentIndex))
+ });
+ } else {
+ _repliesOverlay.load(true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
+ }
+
+ setRepliesOverlayVisible(isVisible = true, animate = true);
+ }
+
+ private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
+ val desiredVisibility = if (isVisible) View.VISIBLE else View.GONE
+ if (_repliesOverlay.visibility == desiredVisibility) {
+ return;
+ }
+
+ _repliesAnimator?.cancel();
+
+ if (isVisible) {
+ _repliesOverlay.visibility = View.VISIBLE;
+
+ if (animate) {
+ _repliesOverlay.translationY = _repliesOverlay.height.toFloat();
+
+ _repliesAnimator = _repliesOverlay.animate()
+ .setDuration(300)
+ .translationY(0f)
+ .withEndAction {
+ _repliesAnimator = null;
+ }.apply { start() };
+ }
+ } else {
+ if (animate) {
+ _repliesOverlay.translationY = 0f;
+
+ _repliesAnimator = _repliesOverlay.animate()
+ .setDuration(300)
+ .translationY(_repliesOverlay.height.toFloat())
+ .withEndAction {
+ _repliesOverlay.visibility = GONE;
+ _repliesAnimator = null;
+ }.apply { start(); }
+ } else {
+ _repliesOverlay.visibility = View.GONE;
+ _repliesOverlay.translationY = _repliesOverlay.height.toFloat();
+ }
+ }
+ }
+
+ private fun updateCommentCountString() {
+ _textCommentCount.text = context.getString(R.string.these_are_all_commentcount_comments_you_have_made_in_grayjay).replace("{commentCount}", _comments.size.toString())
+ }
+
+ private fun setLoading(loading: Boolean) {
+ if (_loading == loading) {
+ return;
+ }
+
+ _loading = loading;
+ _adapterComments.setLoading(loading);
+ }
+
+ private fun fetchComments() {
+ val system = StatePolycentric.instance.processHandle?.system ?: return
+ _comments.clear()
+ _adapterComments.notifyDataSetChanged()
+ setLoading(true)
+ _taskLoadComments.run(system)
+ }
+
+ private fun onCommentsLoaded(comments: List) {
+ setLoading(false)
+ _comments.addAll(comments)
+
+ if (_spinnerSortBy.selectedItemPosition == 0) {
+ _comments.sortByDescending { it.date!! }
+ } else if (_spinnerSortBy.selectedItemPosition == 1) {
+ _comments.sortBy { it.date!! }
+ }
+
+ _adapterComments.notifyDataSetChanged()
+ updateCommentCountString()
+ }
+
+ fun onShown() {
+ _layoutPolycentricNotEnabled.visibility = if (!StatePolycentric.instance.enabled) View.VISIBLE else View.GONE
+
+ val processHandle = StatePolycentric.instance.processHandle
+ if (processHandle != null) {
+ _layoutNotLoggedIn.visibility = View.GONE
+ _recyclerComments.visibility = View.VISIBLE
+ fetchComments()
+ } else {
+ _layoutNotLoggedIn.visibility = View.VISIBLE
+ _recyclerComments.visibility= View.GONE
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt
index 88844108..b62098a7 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt
@@ -6,8 +6,10 @@ import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
+import android.widget.EditText
import android.widget.FrameLayout
import android.widget.Spinner
+import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -21,9 +23,13 @@ class CreatorsFragment : MainFragment() {
private var _spinnerSortBy: Spinner? = null;
private var _overlayContainer: FrameLayout? = null;
+ private var _containerSearch: FrameLayout? = null;
+ private var _editSearch: EditText? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false);
+ _containerSearch = view.findViewById(R.id.container_search);
+ _editSearch = view.findViewById(R.id.edit_search);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate(platformUser) };
@@ -44,6 +50,10 @@ class CreatorsFragment : MainFragment() {
_spinnerSortBy = spinnerSortBy;
+ _editSearch?.addTextChangedListener {
+ adapter.query = it.toString();
+ }
+
val recyclerView = view.findViewById(R.id.recycler_subscriptions);
recyclerView.adapter = adapter;
recyclerView.layoutManager = LinearLayoutManager(view.context);
@@ -54,6 +64,8 @@ class CreatorsFragment : MainFragment() {
super.onDestroyMainView();
_spinnerSortBy = null;
_overlayContainer = null;
+ _editSearch = null;
+ _containerSearch = null;
}
companion object {
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
index c4c28a73..595f880f 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt
@@ -13,7 +13,6 @@ import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
-import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.constructs.Event1
@@ -21,7 +20,6 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.FeedStyle
-import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
@@ -64,6 +62,7 @@ abstract class FeedView : L
val fragment: TFragment;
private val _scrollListener: RecyclerView.OnScrollListener;
+ private var _automaticNextPageCounter = 0;
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) {
this.fragment = fragment;
@@ -122,7 +121,6 @@ abstract class FeedView : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
- var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
@@ -142,15 +140,8 @@ abstract class FeedView : L
val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
- if(filteredResults.isEmpty()) {
- filteredNextPageCounter++
- if(filteredNextPageCounter <= 4)
- loadNextPage()
- }
- else {
- filteredNextPageCounter = 0;
- recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
- }
+ recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
+ ensureEnoughContentVisible(filteredResults)
}.exception {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -170,8 +161,10 @@ abstract class FeedView : L
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
+ //Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
+
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
- //Logger.i(TAG, "loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold _results.size=${_results.size}")
+ //Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
loadNextPage();
}
}
@@ -180,6 +173,33 @@ abstract class FeedView : L
_recyclerResults.addOnScrollListener(_scrollListener);
}
+ private fun ensureEnoughContentVisible(filteredResults: List) {
+ val canScroll = if (recyclerData.results.isEmpty()) false else {
+ val layoutManager = recyclerData.layoutManager
+ val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+
+ if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
+ val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
+ val itemHeight = firstVisibleView?.height ?: 0
+ val occupiedSpace = recyclerData.results.size * itemHeight
+ val recyclerViewHeight = _recyclerResults.height
+ Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
+ occupiedSpace >= recyclerViewHeight
+ } else {
+ false
+ }
+
+ }
+ Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
+ if (!canScroll || filteredResults.isEmpty()) {
+ _automaticNextPageCounter++
+ if(_automaticNextPageCounter <= 4)
+ loadNextPage()
+ } else {
+ _automaticNextPageCounter = 0;
+ }
+ }
+
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
@@ -370,6 +390,7 @@ abstract class FeedView : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
+ ensureEnoughContentVisible(filteredResults)
}
private fun detachPagerEvents() {
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt
index b35cd912..667b6ac9 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt
@@ -224,7 +224,7 @@ class PostDetailFragment : MainFragment {
updateCommentType(false);
};
- _commentsList.onClick.subscribe { c ->
+ _commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@@ -233,7 +233,7 @@ class PostDetailFragment : MainFragment {
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
- _repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
+ _repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@@ -241,7 +241,7 @@ class PostDetailFragment : MainFragment {
parentComment = newComment;
});
} else {
- _repliesOverlay.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
+ _repliesOverlay.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
index d1d067c0..d6588378 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
@@ -37,7 +37,6 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
-import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -52,7 +51,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
-import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
@@ -60,7 +58,6 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
-import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
@@ -110,7 +107,6 @@ import java.time.OffsetDateTime
import kotlin.collections.ArrayList
import kotlin.math.abs
import kotlin.math.roundToLong
-import kotlin.streams.toList
class VideoDetailView : ConstraintLayout {
@@ -580,7 +576,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_current = _container_content_main;
- _commentsList.onClick.subscribe { c ->
+ _commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@@ -589,7 +585,7 @@ class VideoDetailView : ConstraintLayout {
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
- _container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
+ _container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@@ -597,7 +593,7 @@ class VideoDetailView : ConstraintLayout {
parentComment = newComment;
});
} else {
- _container_content_replies.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
+ _container_content_replies.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
switchContentView(_container_content_replies);
};
@@ -1132,7 +1128,7 @@ class VideoDetailView : ConstraintLayout {
_player.setMetadata(video.name, video.author.name);
- _toggleCommentType.setValue(Settings.instance.comments.defaultCommentSection == 1, false);
+ _toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
updateCommentType(true);
//UI
diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
index a2aa67ef..e40f83cb 100644
--- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
+++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
@@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
import android.net.Uri
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.HLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
+import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
@@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
class VideoHelper {
companion object {
- fun isDownloadable(detail: IPlatformVideoDetails) =
- (detail.video.videoSources.any { isDownloadable(it) }) ||
- (if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
- fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
- fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
+ fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
+ if (detail.video.videoSources.any { isDownloadable(it) }) {
+ return true
+ }
+
+ val descriptor = detail.video
+ if (descriptor is VideoUnMuxedSourceDescriptor) {
+ if (descriptor.audioSources.any { isDownloadable(it) }) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
+ fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? {
diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
index e07b8a17..57f42576 100644
--- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
+++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
@@ -1,8 +1,22 @@
package com.futo.platformplayer.parsers
+import android.view.View
+import com.futo.platformplayer.R
+import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
+import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
+import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
+import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
+import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.toYesNo
+import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
+import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.yesNoToBoolean
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@@ -85,6 +99,48 @@ class HLS {
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
}
+ fun parseAndGetVideoSources(source: Any, content: String, url: String): List {
+ val masterPlaylist: MasterPlaylist
+ try {
+ masterPlaylist = parseMasterPlaylist(content, url)
+ return masterPlaylist.getVideoSources()
+ } catch (e: Throwable) {
+ if (content.lines().any { it.startsWith("#EXTINF:") }) {
+ return if (source is IHLSManifestSource) {
+ listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url))
+ } else if (source is IHLSManifestAudioSource) {
+ listOf()
+ } else {
+ throw NotImplementedError()
+ }
+ } else {
+ throw e
+ }
+ }
+ }
+
+ fun parseAndGetAudioSources(source: Any, content: String, url: String): List {
+ val masterPlaylist: MasterPlaylist
+ try {
+ masterPlaylist = parseMasterPlaylist(content, url)
+ return masterPlaylist.getAudioSources()
+ } catch (e: Throwable) {
+ if (content.lines().any { it.startsWith("#EXTINF:") }) {
+ return if (source is IHLSManifestSource) {
+ listOf()
+ } else if (source is IHLSManifestAudioSource) {
+ listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
+ } else {
+ throw NotImplementedError()
+ }
+ } else {
+ throw e
+ }
+ }
+ }
+
+ //TODO: getSubtitleSources
+
private fun resolveUrl(baseUrl: String, url: String): String {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
@@ -269,6 +325,49 @@ class HLS {
return builder.toString()
}
+
+ fun getVideoSources(): List {
+ return variantPlaylistsRefs.map {
+ var width: Int? = null
+ var height: Int? = null
+ val resolutionTokens = it.streamInfo.resolution?.split('x')
+ if (resolutionTokens?.isNotEmpty() == true) {
+ width = resolutionTokens[0].toIntOrNull()
+ height = resolutionTokens[1].toIntOrNull()
+ }
+
+ val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
+ HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
+ }
+ }
+
+ fun getAudioSources(): List {
+ return mediaRenditions.mapNotNull {
+ if (it.uri == null) {
+ return@mapNotNull null
+ }
+
+ val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
+ return@mapNotNull when (it.type) {
+ "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
+ else -> null
+ }
+ }
+ }
+
+ fun getSubtitleSources(): List {
+ return mediaRenditions.mapNotNull {
+ if (it.uri == null) {
+ return@mapNotNull null
+ }
+
+ val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
+ return@mapNotNull when (it.type) {
+ "SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl")
+ else -> null
+ }
+ }
+ }
}
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
diff --git a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt
index dc5ce5bb..08a3f3da 100644
--- a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt
+++ b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt
@@ -7,9 +7,9 @@ import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
-import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
+import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.google.protobuf.ByteString
@@ -19,17 +19,21 @@ import java.nio.ByteBuffer
import java.time.OffsetDateTime
class PolycentricCache {
- data class CachedOwnedClaims(val ownedClaims: List?, val creationTime: OffsetDateTime = OffsetDateTime.now());
+ data class CachedOwnedClaims(val ownedClaims: List?, val creationTime: OffsetDateTime = OffsetDateTime.now()) {
+ val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
+ }
@Serializable
- data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now());
+ data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) {
+ val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
+ }
- private val _cacheExpirationSeconds = 60 * 60 * 3;
private val _cache = hashMapOf()
private val _profileCache = hashMapOf()
private val _profileUrlCache = FragmentedStorage.get("profileUrlCache")
private val _scope = CoroutineScope(Dispatchers.IO);
- private val _taskGetProfile = BatchedTaskHandler(_scope, { system ->
+ private val _taskGetProfile = BatchedTaskHandler(_scope,
+ { system ->
val signedProfileEvents = ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
@@ -140,7 +144,7 @@ class PolycentricCache {
{ _, _ -> });
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
- if (id.claimType <= 0) {
+ if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedOwnedClaims(null);
}
@@ -150,7 +154,7 @@ class PolycentricCache {
return null
}
- if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
+ if (!ignoreExpired && cached.expired) {
return null;
}
@@ -160,7 +164,7 @@ class PolycentricCache {
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
fun getValidClaimsAsync(id: PlatformID): Deferred {
- if (id.value == null || id.claimType <= 0) {
+ if (!StatePolycentric.instance.enabled || id.value == null || id.claimType <= 0) {
return _scope.async { CachedOwnedClaims(null) };
}
@@ -182,13 +186,18 @@ class PolycentricCache {
}
fun getDataAsync(url: String): Deferred {
+ StatePolycentric.instance.ensureEnabled()
return _batchTaskGetData.execute(url);
}
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
+ if (!StatePolycentric.instance.enabled) {
+ return CachedPolycentricProfile(null)
+ }
+
synchronized (_profileCache) {
val cached = _profileUrlCache.get(url) ?: return null;
- if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
+ if (!ignoreExpired && cached.expired) {
return null;
}
@@ -197,9 +206,13 @@ class PolycentricCache {
}
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
+ if (!StatePolycentric.instance.enabled) {
+ return CachedPolycentricProfile(null)
+ }
+
synchronized(_profileCache) {
val cached = _profileCache[system] ?: return null;
- if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
+ if (!ignoreExpired && cached.expired) {
return null;
}
@@ -208,7 +221,7 @@ class PolycentricCache {
}
suspend fun getProfileAsync(id: PlatformID): CachedPolycentricProfile? {
- if (id.claimType <= 0) {
+ if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedPolycentricProfile(null);
}
@@ -234,6 +247,10 @@ class PolycentricCache {
}
fun getProfileAsync(system: PublicKey): Deferred {
+ if (!StatePolycentric.instance.enabled) {
+ return _scope.async { CachedPolycentricProfile(null) };
+ }
+
Logger.i(TAG, "getProfileAsync (system: ${system})")
val def = _taskGetProfile.execute(system);
def.invokeOnCompletion {
@@ -281,6 +298,7 @@ class PolycentricCache {
private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io"
private var _instance: PolycentricCache? = null;
+ private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3;
@JvmStatic
val instance: PolycentricCache
diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt
index a58a9b29..cf6e0ba2 100644
--- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt
+++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt
@@ -162,6 +162,8 @@ class DownloadService : Service() {
Logger.i(TAG, "doDownloading - Ending Downloads");
stopService(this);
}
+
+
private suspend fun doDownload(download: VideoDownload) {
if(!Settings.instance.downloads.shouldDownload())
throw IllegalStateException("Downloading disabled on current network");
@@ -183,14 +185,14 @@ class DownloadService : Service() {
Logger.i(TAG, "Preparing [${download.name}] started");
if(download.state == VideoDownload.State.PREPARING)
- download.prepare();
+ download.prepare(_client);
download.changeState(VideoDownload.State.DOWNLOADING);
notifyDownload(download);
var lastNotifyTime: Long = 0L;
Logger.i(TAG, "Downloading [${download.name}] started");
//TODO: Use plugin client?
- download.download(_client) { progress ->
+ download.download(applicationContext, _client) { progress ->
download.progress = progress;
val currentTime = System.currentTimeMillis();
diff --git a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt
index 7f076304..a5b08b81 100644
--- a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt
+++ b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt
@@ -23,6 +23,7 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
+import com.futo.platformplayer.Settings
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.activities.MainActivity
@@ -49,6 +50,7 @@ class MediaPlaybackService : Service() {
private var _mediaSession: MediaSessionCompat? = null;
private var _hasFocus: Boolean = false;
private var _focusRequest: AudioFocusRequest? = null;
+ private var _audioFocusLossTime_ms: Long? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Logger.v(TAG, "onStartCommand");
@@ -335,16 +337,32 @@ class MediaPlaybackService : Service() {
//Do not start playing on gaining audo focus
//MediaControlReceiver.onPlayReceived.emit();
_hasFocus = true;
- Log.i(TAG, "Audio focus gained");
+ Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms)");
+
+ if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
+ val lossTime_ms = _audioFocusLossTime_ms
+ if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
+ MediaControlReceiver.onPlayReceived.emit()
+ }
+ } else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
+ val lossTime_ms = _audioFocusLossTime_ms
+ if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
+ MediaControlReceiver.onPlayReceived.emit()
+ }
+ } else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
+ MediaControlReceiver.onPlayReceived.emit()
+ }
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
MediaControlReceiver.onPauseReceived.emit();
+ _audioFocusLossTime_ms = System.currentTimeMillis()
Log.i(TAG, "Audio focus transient loss");
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.i(TAG, "Audio focus transient loss, can duck");
}
AudioManager.AUDIOFOCUS_LOSS -> {
+ _audioFocusLossTime_ms = System.currentTimeMillis()
_hasFocus = false;
MediaControlReceiver.onPauseReceived.emit();
Log.i(TAG, "Audio focus lost");
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt
index 76d06783..9bced80b 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt
@@ -1,6 +1,7 @@
package com.futo.platformplayer.states
import android.content.Context
+import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
@@ -256,9 +257,6 @@ class StateAnnouncement {
}
-
-
-
fun registerDidYouKnow() {
val random = Random();
val message: String? = when (random.nextInt(4 * 18 + 1)) {
@@ -294,6 +292,23 @@ class StateAnnouncement {
}
}
+ fun registerDefaultHandlerAnnouncement() {
+ registerAnnouncement(
+ "default-url-handler",
+ "Allow Grayjay to open URLs",
+ "Click here to allow Grayjay to open URLs",
+ AnnouncementType.SESSION_RECURRING,
+ null,
+ null,
+ "Allow"
+ ) {
+ UIDialogs.showUrlHandlingPrompt(StateApp.instance.context) {
+ instance.neverAnnouncement("default-url-handler")
+ instance.onAnnouncementChanged.emit()
+ }
+ }
+ }
+
companion object {
private var _instance: StateAnnouncement? = null;
val instance: StateAnnouncement
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
index 6554b4e9..5b3521a9 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
@@ -218,14 +218,33 @@ class StateApp {
return state;
}
- fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, handle: (DocumentFile?)->Unit) {
+ fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, contentType: String, handle: (DocumentFile?)->Unit) {
if(activity is Context) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-
+ intent.setType(contentType);
+ activity.launchForResult(intent, 98) {
+ if(it.resultCode == Activity.RESULT_OK) {
+ val uri = it.data?.data;
+ if(uri != null)
+ handle(DocumentFile.fromSingleUri(activity, uri));
+ }
+ else
+ UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
+ };
+ }
+ }
+ fun requestFileCreateAccess(activity: IWithResultLauncher, path: Uri?, contentType: String, handle: (DocumentFile?)->Unit) {
+ if(activity is Context) {
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT);
+ if(path != null)
+ intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
+ intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ .or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ intent.setType(contentType);
activity.launchForResult(intent, 98) {
if(it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data;
@@ -526,6 +545,7 @@ class StateApp {
);
}
+ StateAnnouncement.instance.registerDefaultHandlerAnnouncement();
StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished");
diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
index 69fbb5fe..b3a7ea3e 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt
@@ -8,17 +8,21 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
+import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
+import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.writeBytes
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -52,15 +56,6 @@ class StateBackup {
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
return Pair(mainBackupFile, secondaryBackupFile);
}
- /*
- private fun getAutomaticBackupFiles(): Pair {
- val dir = StateApp.instance.getExternalRootDirectory();
- if(dir == null)
- throw IllegalStateException("Can't access external files");
- return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
- }*/
-
-
fun getAllMigrationStores(): List> = listOf(
StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck()
@@ -192,7 +187,19 @@ class StateBackup {
importZipBytes(context, scope, backupBytes);
}
- fun startExternalBackup() {
+ fun saveExternalBackup(activity: IWithResultLauncher) {
+ val data = export();
+ if(activity is Context)
+ StateApp.instance.requestFileCreateAccess(activity, null, "application/zip") {
+ if(it == null) {
+ UIDialogs.toast("Cancelled");
+ return@requestFileCreateAccess;
+ }
+ it.writeBytes(activity, data.asZip());
+ UIDialogs.toast("Export saved");
+ };
+ }
+ fun shareExternalBackup() {
val data = export();
val now = OffsetDateTime.now();
val exportFile = File(
@@ -401,6 +408,46 @@ class StateBackup {
).withCondition { doImport } else null
);
}
+
+ fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
+ if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
+ val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
+ context.navigate(context.getFragment(), lines);
+ return true;
+ }
+ else if(allowFailure) {
+ UIDialogs.showGeneralErrorDialog(context, "Unknown text header [${text}]");
+ }
+ return false;
+ }
+ fun importNewPipeSubs(context: MainActivity, json: String) {
+ val newPipeSubsParsed = JsonParser.parseString(json).asJsonObject;
+ if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
+ UIDialogs.showGeneralErrorDialog(context, "Invalid json");
+ else {
+ importNewPipeSubs(context, newPipeSubsParsed);
+ }
+ }
+ fun importNewPipeSubs(context: MainActivity, obj: JsonObject) {
+ try {
+ val jsonSubs = obj["subscriptions"]
+ val jsonSubsArray = jsonSubs.asJsonArray;
+ val jsonSubsArrayItt = jsonSubsArray.iterator();
+ val subs = mutableListOf()
+ while(jsonSubsArrayItt.hasNext()) {
+ val jsonSubObj = jsonSubsArrayItt.next().asJsonObject;
+
+ if(jsonSubObj.has("url"))
+ subs.add(jsonSubObj["url"].asString);
+ }
+
+ context.navigate(context.getFragment(), subs);
+ }
+ catch(ex: Exception) {
+ Logger.e("StateBackup", ex.message, ex);
+ UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
+ }
+ }
}
class ExportStructure(
diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt
index f294a6f9..6911d0f7 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
import android.content.Context
import android.content.Intent
+import android.util.Log
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -11,17 +12,12 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
-import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
-import com.futo.platformplayer.api.media.structures.PlaceholderPager
-import com.futo.platformplayer.api.media.structures.RefreshChronoContentPager
-import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager
-import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
import com.futo.platformplayer.awaitFirstDeferred
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@@ -38,11 +34,11 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
+import kotlin.Exception
class StatePolycentric {
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
@@ -50,23 +46,55 @@ class StatePolycentric {
var processHandle: ProcessHandle? = null; private set;
private var _likeDislikeMap = hashMapOf()
private val _activeProcessHandle = FragmentedStorage.get("activeProcessHandle");
+ private var _transientEnabled = true
+ val enabled get() = _transientEnabled && Settings.instance.other.polycentricEnabled
fun load(context: Context) {
- val db = SqlLiteDbHelper(context);
- Store.initializeSqlLiteStore(db);
+ if (!enabled) {
+ return
+ }
- val activeProcessHandleString = _activeProcessHandle.value;
- if (activeProcessHandleString.isNotEmpty()) {
- val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
- setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
+ try {
+ val db = SqlLiteDbHelper(context);
+ Store.initializeSqlLiteStore(db);
+
+ val activeProcessHandleString = _activeProcessHandle.value;
+ if (activeProcessHandleString.isNotEmpty()) {
+ try {
+ val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
+ setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
+ } catch (e: Throwable) {
+ db.upgradeOldSecrets(db.writableDatabase);
+
+ val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
+ setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
+
+ Log.i(TAG, "Failed to initialize Polycentric.", e)
+ }
+ }
+ } catch (e: Throwable) {
+ _transientEnabled = false
+ UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
+ Log.i(TAG, "Failed to initialize Polycentric.", e)
+ }
+ }
+
+ fun ensureEnabled() {
+ if (!enabled) {
+ throw Exception("Polycentric is disabled")
}
}
fun getProcessHandles(): List {
+ if (!enabled) {
+ return listOf()
+ }
+
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); };
}
fun setProcessHandle(processHandle: ProcessHandle?) {
+ ensureEnabled()
this.processHandle = processHandle;
if (processHandle != null) {
@@ -96,20 +124,34 @@ class StatePolycentric {
}
fun updateLikeMap(ref: Protocol.Reference, hasLiked: Boolean, hasDisliked: Boolean) {
+ ensureEnabled()
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
}
fun hasDisliked(ref: Protocol.Reference): Boolean {
+ if (!enabled) {
+ return false
+ }
+
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
return entry.hasDisliked;
}
fun hasLiked(ref: Protocol.Reference): Boolean {
+ if (!enabled) {
+ return false
+ }
+
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false;
return entry.hasLiked;
}
fun requireLogin(context: Context, text: String, action: (processHandle: ProcessHandle) -> Unit) {
+ if (!enabled) {
+ UIDialogs.toast(context, "Polycentric is disabled")
+ return
+ }
+
val p = processHandle;
if (p == null) {
Logger.i(TAG, "requireLogin preventPictureInPicture.emit()");
@@ -127,24 +169,10 @@ class StatePolycentric {
}
}
- fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, ignorePlugins: List? = null): IPager {
- //TODO: Currently abusing subscription concurrency for parallelism
- val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
- val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
- val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
- if (!StatePlatform.instance.hasEnabledChannelClient(url)) {
- return@mapNotNull null;
- }
-
- return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, ignorePlugins);
- }.toTypedArray();
-
- val pager = MultiChronoContentPager(pagers);
- pager.initialize();
- return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
- }
-
fun getChannelUrls(url: String, channelId: PlatformID? = null, cacheOnly: Boolean = false): List {
+ if (!enabled) {
+ return listOf(url);
+ }
var polycentricProfile: PolycentricProfile? = null;
try {
@@ -172,7 +200,10 @@ class StatePolycentric {
else
return listOf(url);
}
+
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? {
+ ensureEnabled()
+
//TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
@@ -212,13 +243,78 @@ class StatePolycentric {
StatePlatform.instance.getEnabledClients().map { it.id }
);*/
}
- suspend fun getChannelContent(profile: PolycentricProfile): IPager {
- return withContext(Dispatchers.IO) {
- getChannelContent(this, profile) ?: EmptyPager();
+ fun getSystemComments(context: Context, system: PublicKey): List {
+ if (!enabled) {
+ return listOf()
}
+
+ val dp_25 = 25.dp(context.resources)
+ val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
+ val author = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable())
+ val posts = arrayListOf()
+ Store.instance.enumerateSignedEvents(system, ContentType.POST) { se ->
+ val ev = se.event
+ val post = Protocol.Post.parseFrom(ev.content)
+
+ posts.add(PolycentricPlatformComment(
+ contextUrl = author,
+ author = PlatformAuthorLink(
+ id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
+ name = systemState.username,
+ url = author,
+ thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
+ subscribers = null
+ ),
+ msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
+ rating = RatingLikeDislikes(0, 0),
+ date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
+ replyCount = 0,
+ eventPointer = se.toPointer()
+ ))
+ }
+
+ return posts
+ }
+
+ data class LikesDislikesReplies(
+ var likes: Long,
+ var dislikes: Long,
+ var replyCount: Long
+ )
+
+ suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
+ ensureEnabled()
+
+ val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
+ null,
+ listOf(
+ Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
+ .setFromType(ContentType.OPINION.value)
+ .setValue(ByteString.copyFrom(Opinion.like.data))
+ .build(),
+ Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
+ .setFromType(ContentType.OPINION.value)
+ .setValue(ByteString.copyFrom(Opinion.dislike.data))
+ .build()
+ ),
+ listOf(
+ Protocol.QueryReferencesRequestCountReferences.newBuilder()
+ .setFromType(ContentType.POST.value)
+ .build()
+ )
+ );
+
+ val likes = response.countsList[0];
+ val dislikes = response.countsList[1];
+ val replyCount = response.countsList[2];
+ return LikesDislikesReplies(likes, dislikes, replyCount)
}
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager {
+ if (!enabled) {
+ return EmptyPager()
+ }
+
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.POST.value)
@@ -284,7 +380,7 @@ class StatePolycentric {
};
}
- private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List {
+ private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List {
return response.itemsList.mapNotNull {
val sev = SignedEvent.fromProto(it.event);
val ev = sev.event;
@@ -294,7 +390,6 @@ class StatePolycentric {
try {
val post = Protocol.Post.parseFrom(ev.content);
- val id = ev.system.toProto().key.toByteArray().toBase64();
val likes = it.countsList[0];
val dislikes = it.countsList[1];
val replies = it.countsList[2];
@@ -338,7 +433,7 @@ class StatePolycentric {
rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(),
- reference = sev.toPointer().toReference()
+ eventPointer = sev.toPointer()
);
} catch (e: Throwable) {
return@mapNotNull null;
diff --git a/app/src/main/java/com/futo/platformplayer/views/Loader.kt b/app/src/main/java/com/futo/platformplayer/views/LoaderView.kt
similarity index 83%
rename from app/src/main/java/com/futo/platformplayer/views/Loader.kt
rename to app/src/main/java/com/futo/platformplayer/views/LoaderView.kt
index 8e4a64d3..2e0610e3 100644
--- a/app/src/main/java/com/futo/platformplayer/views/Loader.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/LoaderView.kt
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views
import android.content.Context
+import android.graphics.Color
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
@@ -11,9 +12,10 @@ import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import com.futo.platformplayer.R
-class Loader : LinearLayout {
+class LoaderView : LinearLayout {
private val _imageLoader: ImageView;
private val _automatic: Boolean;
+ private var _isWhite: Boolean;
private val _animatable: Animatable;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -24,18 +26,25 @@ class Loader : LinearLayout {
if (attrs != null) {
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0);
_automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false);
+ _isWhite = attrArr.getBoolean(R.styleable.LoaderView_isWhite, false);
attrArr.recycle();
} else {
_automatic = false;
+ _isWhite = false;
}
visibility = View.GONE;
+
+ if (_isWhite) {
+ _imageLoader.setColorFilter(Color.WHITE)
+ }
}
- constructor(context: Context, automatic: Boolean, height: Int = -1) : super(context) {
+ constructor(context: Context, automatic: Boolean, height: Int = -1, isWhite: Boolean = false) : super(context) {
inflate(context, R.layout.view_loader, this);
_imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable;
_automatic = automatic;
+ _isWhite = isWhite;
if(height > 0) {
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
diff --git a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt
index 4a6f4677..d9e1011c 100644
--- a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt
@@ -41,7 +41,7 @@ class MonetizationView : LinearLayout {
private val _textMerchandise: TextView;
private val _recyclerMerchandise: RecyclerView;
- private val _loaderMerchandise: Loader;
+ private val _loaderViewMerchandise: LoaderView;
private val _layoutMerchandise: FrameLayout;
private var _merchandiseAdapterView: AnyAdapterView? = null;
@@ -81,7 +81,7 @@ class MonetizationView : LinearLayout {
_textMerchandise = findViewById(R.id.text_merchandise);
_recyclerMerchandise = findViewById(R.id.recycler_merchandise);
- _loaderMerchandise = findViewById(R.id.loader_merchandise);
+ _loaderViewMerchandise = findViewById(R.id.loader_merchandise);
_layoutMerchandise = findViewById(R.id.layout_merchandise);
_root = findViewById(R.id.root);
@@ -108,7 +108,7 @@ class MonetizationView : LinearLayout {
}
private fun setMerchandise(items: List?) {
- _loaderMerchandise.stop();
+ _loaderViewMerchandise.stop();
if (items == null) {
_textMerchandise.visibility = View.GONE;
@@ -147,7 +147,7 @@ class MonetizationView : LinearLayout {
val uri = Uri.parse(storeData);
if (uri.isAbsolute) {
_taskLoadMerchandise.run(storeData);
- _loaderMerchandise.start();
+ _loaderViewMerchandise.start();
} else {
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
}
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt
index 79660207..68103c99 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt
@@ -3,6 +3,7 @@ package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -37,8 +38,10 @@ class CommentViewHolder : ViewHolder {
private val _layoutRating: LinearLayout;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
+ private val _buttonDelete: FrameLayout;
- var onClick = Event1();
+ var onRepliesClick = Event1();
+ var onDelete = Event1();
var comment: IPlatformComment? = null
private set;
@@ -55,6 +58,7 @@ class CommentViewHolder : ViewHolder {
_buttonReplies = itemView.findViewById(R.id.button_replies);
_layoutRating = itemView.findViewById(R.id.layout_rating);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
+ _buttonDelete = itemView.findViewById(R.id.button_delete);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
@@ -87,7 +91,12 @@ class CommentViewHolder : ViewHolder {
_buttonReplies.onClick.subscribe {
val c = comment ?: return@subscribe;
- onClick.emit(c);
+ onRepliesClick.emit(c);
+ }
+
+ _buttonDelete.setOnClickListener {
+ val c = comment ?: return@setOnClickListener;
+ onDelete.emit(c);
}
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
@@ -108,7 +117,8 @@ class CommentViewHolder : ViewHolder {
val rating = comment.rating;
if (rating is RatingLikeDislikes) {
- _layoutComment.alpha = if (rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
+ _layoutComment.alpha = if (Settings.instance.comments.badReputationCommentsFading &&
+ rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
} else {
_layoutComment.alpha = 1.0f;
}
@@ -167,6 +177,13 @@ class CommentViewHolder : ViewHolder {
_buttonReplies.visibility = View.GONE;
}
+ val processHandle = StatePolycentric.instance.processHandle
+ if (processHandle != null && comment is PolycentricPlatformComment && processHandle.system == comment.eventPointer.system) {
+ _buttonDelete.visibility = View.VISIBLE
+ } else {
+ _buttonDelete.visibility = View.GONE
+ }
+
this.comment = comment;
}
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt
new file mode 100644
index 00000000..c47db9f6
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt
@@ -0,0 +1,195 @@
+package com.futo.platformplayer.views.adapters
+
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.futo.platformplayer.*
+import com.futo.platformplayer.api.media.models.comments.IPlatformComment
+import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
+import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
+import com.futo.platformplayer.constructs.Event1
+import com.futo.platformplayer.constructs.TaskHandler
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.states.StateApp
+import com.futo.platformplayer.states.StatePolycentric
+import com.futo.platformplayer.views.others.CreatorThumbnail
+import com.futo.platformplayer.views.pills.PillButton
+import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
+import com.futo.polycentric.core.Opinion
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.IdentityHashMap
+
+class CommentWithReferenceViewHolder : ViewHolder {
+ private val _creatorThumbnail: CreatorThumbnail;
+ private val _textAuthor: TextView;
+ private val _textMetadata: TextView;
+ private val _textBody: TextView;
+ private val _buttonReplies: PillButton;
+ private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
+ private val _layoutComment: ConstraintLayout;
+ private val _buttonDelete: FrameLayout;
+ private val _cache: IdentityHashMap;
+ private var _likesDislikesReplies: StatePolycentric.LikesDislikesReplies? = null;
+
+ private val _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, ::getLikesDislikesReplies)
+ .success {
+ _likesDislikesReplies = it
+ updateLikesDislikesReplies()
+ }
+ .exception {
+ Logger.w(TAG, "Failed to get live comment.", it);
+ //TODO: Show error
+ hideLikesDislikesReplies()
+ }
+
+ var onRepliesClick = Event1();
+ var onDelete = Event1();
+ var comment: IPlatformComment? = null
+ private set;
+
+ constructor(viewGroup: ViewGroup, cache: IdentityHashMap) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment_with_reference, viewGroup, false)) {
+ _layoutComment = itemView.findViewById(R.id.layout_comment);
+ _creatorThumbnail = itemView.findViewById(R.id.image_thumbnail);
+ _textAuthor = itemView.findViewById(R.id.text_author);
+ _textMetadata = itemView.findViewById(R.id.text_metadata);
+ _textBody = itemView.findViewById(R.id.text_body);
+ _buttonReplies = itemView.findViewById(R.id.button_replies);
+ _pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
+ _buttonDelete = itemView.findViewById(R.id.button_delete)
+ _cache = cache
+
+ _pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
+ val c = comment
+ if (c !is PolycentricPlatformComment) {
+ throw Exception("Not implemented for non polycentric comments")
+ }
+
+ if (args.hasLiked) {
+ args.processHandle.opinion(c.reference, Opinion.like);
+ } else if (args.hasDisliked) {
+ args.processHandle.opinion(c.reference, Opinion.dislike);
+ } else {
+ args.processHandle.opinion(c.reference, Opinion.neutral);
+ }
+
+ _layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
+
+ StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
+ try {
+ Logger.i(TAG, "Started backfill");
+ args.processHandle.fullyBackfillServersAnnounceExceptions();
+ Logger.i(TAG, "Finished backfill");
+ } catch (e: Throwable) {
+ Logger.e(TAG, "Failed to backfill servers.", e)
+ }
+ }
+
+ StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
+ };
+
+ _buttonReplies.onClick.subscribe {
+ val c = comment ?: return@subscribe;
+ onRepliesClick.emit(c);
+ }
+
+ _buttonDelete.setOnClickListener {
+ val c = comment ?: return@setOnClickListener;
+ onDelete.emit(c);
+ }
+
+ _textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
+ }
+
+ private suspend fun getLikesDislikesReplies(c: PolycentricPlatformComment): StatePolycentric.LikesDislikesReplies {
+ val likesDislikesReplies = StatePolycentric.instance.getLikesDislikesReplies(c.reference)
+ synchronized(_cache) {
+ _cache[c] = likesDislikesReplies
+ }
+ return likesDislikesReplies
+ }
+
+ fun bind(comment: IPlatformComment) {
+ Log.i(TAG, "bind")
+
+ _likesDislikesReplies = null;
+ _taskGetLiveComment.cancel()
+
+ _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
+ _creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
+ _textAuthor.text = comment.author.name;
+
+ val date = comment.date;
+ if (date != null) {
+ _textMetadata.visibility = View.VISIBLE;
+ _textMetadata.text = " • ${date.toHumanNowDiffString()} ago";
+ } else {
+ _textMetadata.visibility = View.GONE;
+ }
+
+ val rating = comment.rating;
+ if (rating is RatingLikeDislikes) {
+ _layoutComment.alpha = if (Settings.instance.comments.badReputationCommentsFading &&
+ rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
+ } else {
+ _layoutComment.alpha = 1.0f;
+ }
+
+ _textBody.text = comment.message.fixHtmlLinks();
+
+ this.comment = comment;
+ updateLikesDislikesReplies();
+ }
+
+ private fun updateLikesDislikesReplies() {
+ Log.i(TAG, "updateLikesDislikesReplies")
+
+ val c = comment ?: return
+ if (c is PolycentricPlatformComment) {
+ if (_likesDislikesReplies == null) {
+ Log.i(TAG, "updateLikesDislikesReplies retrieving from cache")
+
+ synchronized(_cache) {
+ _likesDislikesReplies = _cache[c]
+ }
+ }
+
+ val likesDislikesReplies = _likesDislikesReplies
+ if (likesDislikesReplies != null) {
+ Log.i(TAG, "updateLikesDislikesReplies set")
+
+ val hasLiked = StatePolycentric.instance.hasLiked(c.reference);
+ val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference);
+ _pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
+
+ _buttonReplies.setLoading(false)
+
+ val replies = likesDislikesReplies.replyCount ?: 0;
+ _buttonReplies.visibility = View.VISIBLE;
+ _buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies);
+ } else {
+ Log.i(TAG, "updateLikesDislikesReplies to load")
+
+ _pillRatingLikesDislikes.setLoading(true)
+ _buttonReplies.setLoading(true)
+ _taskGetLiveComment.run(c)
+ }
+ } else {
+ hideLikesDislikesReplies()
+ }
+ }
+
+ private fun hideLikesDislikesReplies() {
+ _pillRatingLikesDislikes.visibility = View.GONE
+ _buttonReplies.visibility = View.GONE
+ }
+
+ companion object {
+ private const val TAG = "CommentWithReferenceViewHolder";
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt
index 6eddcc98..8d4a6f0a 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt
@@ -74,9 +74,9 @@ class DeviceViewHolder : ViewHolder {
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
- } else if (d is FastCastCastingDevice) {
+ } else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
- _textType.text = "FastCast";
+ _textType.text = "FCast";
}
_textName.text = d.name;
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt
index 75e57c47..fe33c0da 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt
@@ -15,7 +15,12 @@ class SubscriptionAdapter : RecyclerView.Adapter {
var onClick = Event1();
var onSettings = Event1();
- var sortBy: Int = 3
+ var sortBy: Int = 5
+ set(value) {
+ field = value
+ updateDataset()
+ }
+ var query: String? = null
set(value) {
field = value;
updateDataset();
@@ -53,6 +58,7 @@ class SubscriptionAdapter : RecyclerView.Adapter {
}
private fun updateDataset() {
+ val queryLower = query?.lowercase() ?: "";
_sortedDataset = when (sortBy) {
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name.lowercase() })
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name.lowercase() })
@@ -61,7 +67,9 @@ class SubscriptionAdapter : RecyclerView.Adapter {
4 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackSeconds }
5 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds }
else -> throw IllegalStateException("Invalid sorting algorithm selected.");
- }.toList();
+ }
+ .filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) }
+ .toList();
notifyDataSetChanged();
}
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt
index 80f10782..8a28c260 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt
@@ -81,17 +81,19 @@ class SubscriptionViewHolder : ViewHolder {
this.subscription = sub;
+ _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
+ _taskLoadProfile.run(sub.channel.id);
+ _textName.text = sub.channel.name;
+ bindViewMetrics(sub);
+ _platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
+
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(sub, cachedProfile, false);
- } else {
- _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
- _taskLoadProfile.run(sub.channel.id);
- _textName.text = sub.channel.name;
- bindViewMetrics(sub);
+ if (cachedProfile.expired) {
+ _taskLoadProfile.run(sub.channel.id);
+ }
}
-
- _platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
}
private fun onProfileLoaded(sub: Subscription?, cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt
index 6644d7eb..d66bb466 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt
@@ -19,7 +19,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
-import com.futo.platformplayer.views.Loader
+import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.platform.PlatformIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -28,7 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView {
protected val _platformIndicatorNested: PlatformIndicator;
protected val _containerLoader: LinearLayout;
- protected val _loader: Loader;
+ protected val _loaderView: LoaderView;
protected val _containerUnavailable: LinearLayout;
protected val _textNestedUrl: TextView;
@@ -42,7 +42,7 @@ class PreviewNestedVideoView : PreviewVideoView {
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
_containerLoader = findViewById(R.id.container_loader);
- _loader = findViewById(R.id.loader);
+ _loaderView = findViewById(R.id.loader);
_containerUnavailable = findViewById(R.id.container_unavailable);
_textNestedUrl = findViewById(R.id.text_nested_url);
@@ -116,7 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView {
if(!_contentSupported) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
- _loader.stop();
+ _loaderView.stop();
}
else {
if(_feedStyle == FeedStyle.THUMBNAIL)
@@ -132,14 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView {
_contentSupported = false;
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
- _loader.stop();
+ _loaderView.stop();
}
}
private fun loadNested(content: IPlatformNestedContent, onCompleted: ((IPlatformContentDetails)->Unit)? = null) {
Logger.i(TAG, "Loading nested content [${content.contentUrl}]");
_containerLoader.visibility = View.VISIBLE;
- _loader.start();
+ _loaderView.start();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
def.invokeOnCompletion {
@@ -150,13 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView {
if(_content == content) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
- _loader.stop();
+ _loaderView.stop();
}
//TODO: Handle exception
}
else if(_content == content) {
_containerLoader.visibility = View.GONE;
- _loader.stop();
+ _loaderView.stop();
val nestedContent = def.getCompleted();
_contentNested = nestedContent;
if(nestedContent is IPlatformVideoDetails) {
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt
index 959043ed..c7a5ee22 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt
@@ -176,20 +176,23 @@ open class PreviewVideoView : LinearLayout {
stopPreview();
+ _imageNeopassChannel?.visibility = View.GONE;
+ _creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
+ _imageChannel?.let {
+ Glide.with(_imageChannel)
+ .load(content.author.thumbnail)
+ .placeholder(R.drawable.placeholder_channel_thumbnail)
+ .into(_imageChannel);
+ }
+ _taskLoadProfile.run(content.author.id);
+ _textChannelName.text = content.author.name
+
val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
- } else {
- _imageNeopassChannel?.visibility = View.GONE;
- _creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
- _imageChannel?.let {
- Glide.with(_imageChannel)
- .load(content.author.thumbnail)
- .placeholder(R.drawable.placeholder_channel_thumbnail)
- .into(_imageChannel);
+ if (cachedProfile.expired) {
+ _taskLoadProfile.run(content.author.id);
}
- _taskLoadProfile.run(content.author.id);
- _textChannelName.text = content.author.name
}
_imageChannel?.clipToOutline = true;
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt
index 487ac8e7..845a179c 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt
@@ -65,13 +65,16 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
override fun bind(authorLink: PlatformAuthorLink) {
_taskLoadProfile.cancel();
+ _creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
+ _taskLoadProfile.run(authorLink.id);
+ _textName.text = authorLink.name;
+
val cachedProfile = PolycentricCache.instance.getCachedProfile(authorLink.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
- } else {
- _creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
- _taskLoadProfile.run(authorLink.id);
- _textName.text = authorLink.name;
+ if (cachedProfile.expired) {
+ _taskLoadProfile.run(authorLink.id);
+ }
}
if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L)
diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt
index 1b2d9c37..0661b28d 100644
--- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt
@@ -51,13 +51,16 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
_channel = subscription.channel;
+ _creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
+ _taskLoadProfile.run(subscription.channel.id);
+ _name.text = subscription.channel.name;
+
val cachedProfile = PolycentricCache.instance.getCachedProfile(subscription.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
- } else {
- _creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
- _taskLoadProfile.run(subscription.channel.id);
- _name.text = subscription.channel.name;
+ if (cachedProfile.expired) {
+ _taskLoadProfile.run(subscription.channel.id);
+ }
}
_subscription = subscription;
diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt
index 35e65329..7b33534e 100644
--- a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt
@@ -6,7 +6,6 @@ import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
-import android.util.Log
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
@@ -63,11 +62,15 @@ class GestureControlView : LinearLayout {
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
+ private val _gestureController: GestureDetectorCompat;
+
val onSeek = Event1();
val onBrightnessAdjusted = Event1();
val onSoundAdjusted = Event1();
val onToggleFullscreen = Event0();
+ var fullScreenGestureEnabled = true
+
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_gesture_controls, this, true);
@@ -82,13 +85,8 @@ class GestureControlView : LinearLayout {
_layoutControlsBrightness = findViewById(R.id.layout_controls_brightness);
_progressBrightness = findViewById(R.id.progress_brightness);
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
- }
- fun setupTouchArea(view: View, layoutControls: ViewGroup? = null, background: View? = null) {
- _layoutControls = layoutControls;
- _background = background;
-
- val gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
+ _gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
override fun onDown(p0: MotionEvent): Boolean { return false; }
override fun onShowPress(p0: MotionEvent) = Unit;
override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; }
@@ -116,15 +114,14 @@ class GestureControlView : LinearLayout {
_fullScreenFactorDown = (_fullScreenFactorDown + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorDown;
} else {
- val rx = p0.x / width;
- val ry = p0.y / height;
- Logger.v(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
+ val rx = (p0.x + p1.x) / (2 * width);
+ val ry = (p0.y + p1.y) / (2 * height);
if (ry > 0.1 && ry < 0.9) {
- if (_isFullScreen && rx < 0.4) {
+ if (_isFullScreen && rx < 0.2) {
startAdjustingBrightness();
- } else if (_isFullScreen && rx > 0.6) {
+ } else if (_isFullScreen && rx > 0.8) {
startAdjustingSound();
- } else if (rx >= 0.4 && rx <= 0.6) {
+ } else if (fullScreenGestureEnabled && rx in 0.3..0.7) {
if (_isFullScreen) {
startAdjustingFullscreenDown();
} else {
@@ -139,7 +136,7 @@ class GestureControlView : LinearLayout {
override fun onLongPress(p0: MotionEvent) = Unit
});
- gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
+ _gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (_skipping) {
return false;
@@ -169,52 +166,58 @@ class GestureControlView : LinearLayout {
}
});
- val touchListener = object : OnTouchListener {
- override fun onTouch(v: View?, ev: MotionEvent): Boolean {
- cancelHideJob();
+ isClickable = true
+ }
- if (_skipping) {
- if (ev.action == MotionEvent.ACTION_UP) {
- startExitFastForward();
- stopAutoFastForward();
- } else if (ev.action == MotionEvent.ACTION_DOWN) {
- _jobExitFastForward?.cancel();
- _jobExitFastForward = null;
+ fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
+ _layoutControls = layoutControls;
+ _background = background;
+ }
- startAutoFastForward();
- fastForwardTick();
- }
- }
+ override fun onTouchEvent(event: MotionEvent?): Boolean {
+ val ev = event ?: return super.onTouchEvent(event);
- if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
- stopAdjustingSound();
- }
+ cancelHideJob();
- if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
- stopAdjustingBrightness();
- }
+ if (_skipping) {
+ if (ev.action == MotionEvent.ACTION_UP) {
+ startExitFastForward();
+ stopAutoFastForward();
+ } else if (ev.action == MotionEvent.ACTION_DOWN) {
+ _jobExitFastForward?.cancel();
+ _jobExitFastForward = null;
- if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
- if (_fullScreenFactorUp > 0.5) {
- onToggleFullscreen.emit();
- }
- stopAdjustingFullscreenUp();
- }
-
- if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
- if (_fullScreenFactorDown > 0.5) {
- onToggleFullscreen.emit();
- }
- stopAdjustingFullscreenDown();
- }
-
- startHideJobIfNecessary();
- return gestureController.onTouchEvent(ev);
+ startAutoFastForward();
+ fastForwardTick();
}
- };
+ }
- view.setOnTouchListener(touchListener);
- view.isClickable = true;
+ if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
+ stopAdjustingSound();
+ }
+
+ if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
+ stopAdjustingBrightness();
+ }
+
+ if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
+ if (_fullScreenFactorUp > 0.5) {
+ onToggleFullscreen.emit();
+ }
+ stopAdjustingFullscreenUp();
+ }
+
+ if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
+ if (_fullScreenFactorDown > 0.5) {
+ onToggleFullscreen.emit();
+ }
+ stopAdjustingFullscreenDown();
+ }
+
+ startHideJobIfNecessary();
+
+ _gestureController.onTouchEvent(ev)
+ return true;
}
fun cancelHideJob() {
diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt
index 0c0be08b..5045f59d 100644
--- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt
@@ -58,7 +58,8 @@ class CastView : ConstraintLayout {
_timeBar = findViewById(R.id.time_progress);
_background = findViewById(R.id.layout_background);
_gestureControlView = findViewById(R.id.gesture_control);
- _gestureControlView.setupTouchArea(_background);
+ _gestureControlView.fullScreenGestureEnabled = false
+ _gestureControlView.setupTouchArea();
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt
index 44cb6020..77a0ba88 100644
--- a/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt
@@ -4,15 +4,21 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
+import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
+import com.futo.platformplayer.toHumanNowDiffString
+import com.futo.platformplayer.views.behavior.NonScrollingTextView
import com.futo.platformplayer.views.comments.AddCommentView
+import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.segments.CommentsList
import userpackage.Protocol
@@ -22,6 +28,11 @@ class RepliesOverlay : LinearLayout {
private val _topbar: OverlayTopbar;
private val _commentsList: CommentsList;
private val _addCommentView: AddCommentView;
+ private val _textBody: NonScrollingTextView;
+ private val _textAuthor: TextView;
+ private val _textMetadata: TextView;
+ private val _creatorThumbnail: CreatorThumbnail;
+ private val _layoutParentComment: ConstraintLayout;
private var _readonly = false;
private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null;
@@ -30,6 +41,11 @@ class RepliesOverlay : LinearLayout {
_topbar = findViewById(R.id.topbar);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
+ _textBody = findViewById(R.id.text_body)
+ _textMetadata = findViewById(R.id.text_metadata)
+ _textAuthor = findViewById(R.id.text_author)
+ _creatorThumbnail = findViewById(R.id.image_thumbnail)
+ _layoutParentComment = findViewById(R.id.layout_parent_comment)
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
@@ -42,7 +58,7 @@ class RepliesOverlay : LinearLayout {
}
}
- _commentsList.onClick.subscribe { c ->
+ _commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount;
var metadata = "";
if (replyCount != null && replyCount > 0) {
@@ -50,9 +66,9 @@ class RepliesOverlay : LinearLayout {
}
if (c is PolycentricPlatformComment) {
- load(false, metadata, c.contextUrl, c.reference, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) });
+ load(false, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) });
} else {
- load(true, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
+ load(true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
};
@@ -60,7 +76,7 @@ class RepliesOverlay : LinearLayout {
_topbar.setInfo(context.getString(R.string.Replies), "");
}
- fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, loader: suspend () -> IPager, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) {
+ fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, parentComment: IPlatformComment? = null, loader: suspend () -> IPager, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) {
_readonly = readonly;
if (readonly) {
_addCommentView.visibility = View.GONE;
@@ -69,6 +85,26 @@ class RepliesOverlay : LinearLayout {
_addCommentView.setContext(contextUrl, ref);
}
+ if (parentComment == null) {
+ _layoutParentComment.visibility = View.GONE
+ } else {
+ _layoutParentComment.visibility = View.VISIBLE
+
+ _textBody.text = parentComment.message.fixHtmlLinks()
+ _textAuthor.text = parentComment.author.name
+
+ val date = parentComment.date
+ if (date != null) {
+ _textMetadata.visibility = View.VISIBLE
+ _textMetadata.text = " • ${date.toHumanNowDiffString()} ago"
+ } else {
+ _textMetadata.visibility = View.GONE
+ }
+
+ _creatorThumbnail.setThumbnail(parentComment.author.thumbnail, false);
+ _creatorThumbnail.setHarborAvailable(parentComment is PolycentricPlatformComment,false);
+ }
+
_topbar.setInfo(context.getString(R.string.Replies), metadata);
_commentsList.load(readonly, loader);
_onCommentAdded = onCommentAdded;
diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
index 0cb71490..373132c4 100644
--- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
@@ -25,7 +25,7 @@ class SlideUpMenuItem : RelativeLayout {
init();
}
- constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){
+ constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any?, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){
init();
_image.setImageResource(imageRes);
_text.text = mainText;
diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt
index cc8e30f1..2c34dc5e 100644
--- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt
@@ -73,8 +73,9 @@ class SlideUpMenuOverlay : RelativeLayout {
item.setParentClickListener { hide() };
else if(item is SlideUpMenuItem)
item.setParentClickListener { hide() };
-
}
+
+ _groupItems = items;
}
private fun init(animated: Boolean, okText: String?){
diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt
index 014a24b4..2a84c372 100644
--- a/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt
@@ -9,16 +9,20 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
+import com.futo.platformplayer.views.LoaderView
class PillButton : LinearLayout {
val icon: ImageView;
val text: TextView;
+ val loaderView: LoaderView;
val onClick = Event0();
+ private var _isLoading = false;
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
icon = findViewById(R.id.pill_icon);
text = findViewById(R.id.pill_text);
+ loaderView = findViewById(R.id.loader)
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
@@ -31,7 +35,29 @@ class PillButton : LinearLayout {
text.text = attrText;
findViewById(R.id.root).setOnClickListener {
+ if (_isLoading) {
+ return@setOnClickListener
+ }
+
onClick.emit();
};
}
+
+ fun setLoading(loading: Boolean) {
+ if (loading == _isLoading) {
+ return
+ }
+
+ if (loading) {
+ text.visibility = View.GONE
+ loaderView.visibility = View.VISIBLE
+ loaderView.start()
+ } else {
+ loaderView.stop()
+ text.visibility = View.VISIBLE
+ loaderView.visibility = View.GONE
+ }
+
+ _isLoading = loading
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt
index f56feced..8854f606 100644
--- a/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt
@@ -16,6 +16,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber
+import com.futo.platformplayer.views.LoaderView
import com.futo.polycentric.core.ProcessHandle
data class OnLikeDislikeUpdatedArgs(
@@ -29,9 +30,12 @@ data class OnLikeDislikeUpdatedArgs(
class PillRatingLikesDislikes : LinearLayout {
private val _textLikes: TextView;
private val _textDislikes: TextView;
+ private val _loaderViewLikes: LoaderView;
+ private val _loaderViewDislikes: LoaderView;
private val _seperator: View;
private val _iconLikes: ImageView;
private val _iconDislikes: ImageView;
+ private var _isLoading: Boolean = false;
private var _likes = 0L;
private var _hasLiked = false;
@@ -47,14 +51,42 @@ class PillRatingLikesDislikes : LinearLayout {
_seperator = findViewById(R.id.pill_seperator);
_iconDislikes = findViewById(R.id.pill_dislike_icon);
_iconLikes = findViewById(R.id.pill_like_icon);
+ _loaderViewLikes = findViewById(R.id.loader_likes)
+ _loaderViewDislikes = findViewById(R.id.loader_dislikes)
- _iconLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
- _textLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
- _iconDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
- _textDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
+ _iconLikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
+ _textLikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
+ _iconDislikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
+ _textDislikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
+ }
+
+ fun setLoading(loading: Boolean) {
+ if (_isLoading == loading) {
+ return
+ }
+
+ if (loading) {
+ _textLikes.visibility = View.GONE
+ _loaderViewLikes.visibility = View.VISIBLE
+ _textDislikes.visibility = View.GONE
+ _loaderViewDislikes.visibility = View.VISIBLE
+ _loaderViewLikes.start()
+ _loaderViewDislikes.start()
+ } else {
+ _loaderViewLikes.stop()
+ _loaderViewDislikes.stop()
+ _textLikes.visibility = View.VISIBLE
+ _loaderViewLikes.visibility = View.GONE
+ _textDislikes.visibility = View.VISIBLE
+ _loaderViewDislikes.visibility = View.GONE
+ }
+
+ _isLoading = loading
}
fun setRating(rating: IRating, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
+ setLoading(false)
+
when (rating) {
is RatingLikeDislikes -> {
setRating(rating, hasLiked, hasDisliked);
@@ -127,6 +159,8 @@ class PillRatingLikesDislikes : LinearLayout {
}
fun setRating(rating: RatingLikeDislikes, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
+ setLoading(false)
+
_textLikes.text = rating.likes.toHumanNumber();
_textDislikes.text = rating.dislikes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
@@ -140,6 +174,8 @@ class PillRatingLikesDislikes : LinearLayout {
updateColors();
}
fun setRating(rating: RatingLikes, hasLiked: Boolean = false) {
+ setLoading(false)
+
_textLikes.text = rating.likes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.GONE;
diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt
index 0677aa99..e02205b7 100644
--- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt
@@ -1,10 +1,14 @@
package com.futo.platformplayer.views.segments
import android.content.Context
+import android.graphics.Color
import android.util.AttributeSet
+import android.view.Gravity
+import android.view.KeyCharacterMap.UnavailableException
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
+import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -19,22 +23,33 @@ import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
-import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
+import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
+import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
+import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import java.net.UnknownHostException
class CommentsList : ConstraintLayout {
private val _llmReplies: LinearLayoutManager;
+ private val _textMessage: TextView;
private val _taskLoadComments = if(!isInEditMode) TaskHandler IPager, IPager>(StateApp.instance.scopeGetter, { it(); })
.success { pager -> onCommentsLoaded(pager); }
.exception {
- UIDialogs.toast("Failed to load comments");
+ setMessage("UnknownHostException: " + it.message);
+ Logger.e(TAG, "Failed to load comments.", it);
+ setLoading(false);
+ }
+ .exception {
+ setMessage(it.message);
+ Logger.e(TAG, "Failed to load comments.", it);
setLoading(false);
}
.exception {
+ setMessage("Throwable: " + it.message);
Logger.e(TAG, "Failed to load comments.", it);
- UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
//UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_comments) + (it.message ?: ""), it, ::fetchComments);
setLoading(false);
} else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
@@ -69,23 +84,35 @@ class CommentsList : ConstraintLayout {
private val _prependedView: FrameLayout;
private var _readonly: Boolean = false;
- var onClick = Event1();
+ var onRepliesClick = Event1();
var onCommentsLoaded = Event1();
+
+
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true);
_recyclerComments = findViewById(R.id.recycler_comments);
+ _textMessage = TextView(context).apply {
+ layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply {
+ setMargins(0, 30, 0, 0)
+ }
+ textSize = 12.0f
+ setTextColor(Color.WHITE)
+ typeface = resources.getFont(R.font.inter_regular)
+ gravity = Gravity.CENTER_HORIZONTAL
+ }
_prependedView = FrameLayout(context);
_prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
- _adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(),
+ _adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView, _textMessage), arrayListOf(),
childCountGetter = { _comments.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position], _readonly); },
childViewHolderFactory = { viewGroup, _ ->
val holder = CommentViewHolder(viewGroup);
- holder.onClick.subscribe { c -> onClick.emit(c) };
+ holder.onRepliesClick.subscribe { c -> onRepliesClick.emit(c) };
+ holder.onDelete.subscribe(::onDelete);
return@InsertedViewAdapterWithLoader holder;
}
);
@@ -96,6 +123,16 @@ class CommentsList : ConstraintLayout {
_recyclerComments.addOnScrollListener(_scrollListener);
}
+ private fun setMessage(message: String?) {
+ Logger.i(TAG, "setMessage " + message)
+ if (message != null) {
+ _textMessage.visibility = View.VISIBLE
+ _textMessage.text = message
+ } else {
+ _textMessage.visibility = View.GONE
+ }
+ }
+
fun addComment(comment: IPlatformComment) {
_comments.add(0, comment);
_adapterComments.notifyItemRangeInserted(_adapterComments.childToParentPosition(0), 1);
@@ -106,6 +143,38 @@ class CommentsList : ConstraintLayout {
_prependedView.addView(view);
}
+ private fun onDelete(comment: IPlatformComment) {
+ UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete this comment?", {
+ val processHandle = StatePolycentric.instance.processHandle ?: return@showConfirmationDialog
+ if (comment !is PolycentricPlatformComment) {
+ return@showConfirmationDialog
+ }
+
+ val index = _comments.indexOf(comment)
+ if (index != -1) {
+ _comments.removeAt(index)
+ _adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
+
+ StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
+ try {
+ processHandle.delete(comment.eventPointer.process, comment.eventPointer.logicalClock)
+ } catch (e: Throwable) {
+ Logger.e(TAG, "Failed to delete event.", e);
+ return@launch;
+ }
+
+ try {
+ Logger.i(TAG, "Started backfill");
+ processHandle.fullyBackfillServersAnnounceExceptions();
+ Logger.i(TAG, "Finished backfill");
+ } catch (e: Throwable) {
+ Logger.e(TAG, "Failed to fully backfill servers.", e);
+ }
+ }
+ }
+ })
+ }
+
private fun onScrolled() {
val visibleItemCount = _recyclerComments.childCount;
val firstVisibleItem = _llmReplies.findFirstVisibleItemPosition();
@@ -147,6 +216,7 @@ class CommentsList : ConstraintLayout {
fun load(readonly: Boolean, loader: suspend () -> IPager) {
cancel();
+ setMessage(null);
_readonly = readonly;
setLoading(true);
@@ -177,6 +247,7 @@ class CommentsList : ConstraintLayout {
_comments.clear();
_commentsPager = null;
_adapterComments.notifyDataSetChanged();
+ setMessage(null);
}
fun cancel() {
diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt
index 01eb189c..3b1c9430 100644
--- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt
@@ -156,7 +156,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_layoutControls = findViewById(R.id.layout_controls);
gestureControl = findViewById(R.id.gesture_control);
- _videoView?.videoSurfaceView?.let { gestureControl.setupTouchArea(it, _layoutControls, background); };
+ gestureControl.setupTouchArea(_layoutControls, background);
gestureControl.onSeek.subscribe { seekFromCurrent(it); };
gestureControl.onSoundAdjusted.subscribe { setVolume(it) };
gestureControl.onToggleFullscreen.subscribe { setFullScreen(!isFullScreen) };
diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
index 3f873b8c..ae1a109b 100644
--- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
@@ -1,10 +1,10 @@
package com.futo.platformplayer.views.video
import android.content.Context
-import android.media.session.PlaybackState
import android.net.Uri
import android.util.AttributeSet
import android.widget.RelativeLayout
+import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
@@ -16,6 +16,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlR
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.constructs.Event1
+import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.video.PlayerManager
import com.google.android.exoplayer2.*
@@ -54,6 +55,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private var _lastSubtitleMediaSource: MediaSource? = null;
private var _shouldPlaybackRestartOnConnectivity: Boolean = false;
private val _referenceObject = Object();
+ private var _connectivityLossTime_ms: Long? = null
private var _chapters: List? = null;
@@ -152,7 +154,24 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val pos = position;
val dur = duration;
+ var shouldRestartPlayback = false
if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) {
+ if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 1) {
+ val lossTime_ms = _connectivityLossTime_ms
+ if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
+ shouldRestartPlayback = true
+ }
+ } else if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 2) {
+ val lossTime_ms = _connectivityLossTime_ms
+ if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
+ shouldRestartPlayback = true
+ }
+ } else if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 3) {
+ shouldRestartPlayback = true
+ }
+ }
+
+ if (shouldRestartPlayback) {
Logger.i(TAG, "Playback ended due to connection loss, resuming playback since connection is restored.");
exoPlayer?.player?.playWhenReady = true;
exoPlayer?.player?.prepare();
@@ -509,16 +528,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
onDatasourceError.emit(error);
}
- PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
- PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
- PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
+ //PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
+ //PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
+ //PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
- PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
- PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
+ //PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
+ //PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
Logger.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true");
_shouldPlaybackRestartOnConnectivity = true;
+ _connectivityLossTime_ms = System.currentTimeMillis()
}
}
}
@@ -536,8 +556,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false");
_shouldPlaybackRestartOnConnectivity = false;
}
-
-
}
companion object {
diff --git a/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto b/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto
new file mode 100644
index 00000000..395c4889
--- /dev/null
+++ b/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto
@@ -0,0 +1,18 @@
+syntax = "proto2";
+option optimize_for = LITE_RUNTIME;
+package com.futo.platformplayer.protos;
+
+message CastMessage {
+ enum ProtocolVersion { CASTV2_1_0 = 0; }
+ required ProtocolVersion protocol_version = 1;
+ required string source_id = 2;
+ required string destination_id = 3;
+ required string namespace = 4;
+ enum PayloadType {
+ STRING = 0;
+ BINARY = 1;
+ }
+ required PayloadType payload_type = 5;
+ optional string payload_utf8 = 6;
+ optional bytes payload_binary = 7;
+}
\ No newline at end of file
diff --git a/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto b/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto
deleted file mode 100644
index f6b090d9..00000000
--- a/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto
+++ /dev/null
@@ -1,82 +0,0 @@
-syntax = "proto2";
-option optimize_for = LITE_RUNTIME;
-package com.futo.platformplayer.protos;
-
-message CastMessage {
- // Always pass a version of the protocol for future compatibility
- // requirements.
- enum ProtocolVersion { CASTV2_1_0 = 0; }
- required ProtocolVersion protocol_version = 1;
- // source and destination ids identify the origin and destination of the
- // message. They are used to route messages between endpoints that share a
- // device-to-device channel.
- //
- // For messages between applications:
- // - The sender application id is a unique identifier generated on behalf of
- // the sender application.
- // - The receiver id is always the the session id for the application.
- //
- // For messages to or from the sender or receiver platform, the special ids
- // 'sender-0' and 'receiver-0' can be used.
- //
- // For messages intended for all endpoints using a given channel, the
- // wildcard destination_id '*' can be used.
- required string source_id = 2;
- required string destination_id = 3;
- // This is the core multiplexing key. All messages are sent on a namespace
- // and endpoints sharing a channel listen on one or more namespaces. The
- // namespace defines the protocol and semantics of the message.
- required string namespace = 4;
- // Encoding and payload info follows.
- // What type of data do we have in this message.
- enum PayloadType {
- STRING = 0;
- BINARY = 1;
- }
- required PayloadType payload_type = 5;
- // Depending on payload_type, exactly one of the following optional fields
- // will always be set.
- optional string payload_utf8 = 6;
- optional bytes payload_binary = 7;
-}
-enum SignatureAlgorithm {
- UNSPECIFIED = 0;
- RSASSA_PKCS1v15 = 1;
- RSASSA_PSS = 2;
-}
-enum HashAlgorithm {
- SHA1 = 0;
- SHA256 = 1;
-}
-// Messages for authentication protocol between a sender and a receiver.
-message AuthChallenge {
- optional SignatureAlgorithm signature_algorithm = 1
- [default = RSASSA_PKCS1v15];
- optional bytes sender_nonce = 2;
- optional HashAlgorithm hash_algorithm = 3 [default = SHA1];
-}
-message AuthResponse {
- required bytes signature = 1;
- required bytes client_auth_certificate = 2;
- repeated bytes intermediate_certificate = 3;
- optional SignatureAlgorithm signature_algorithm = 4
- [default = RSASSA_PKCS1v15];
- optional bytes sender_nonce = 5;
- optional HashAlgorithm hash_algorithm = 6 [default = SHA1];
- optional bytes crl = 7;
-}
-message AuthError {
- enum ErrorType {
- INTERNAL_ERROR = 0;
- NO_TLS = 1; // The underlying connection is not TLS
- SIGNATURE_ALGORITHM_UNAVAILABLE = 2;
- }
- required ErrorType error_type = 1;
-}
-message DeviceAuthMessage {
- // Request fields
- optional AuthChallenge challenge = 1;
- // Response fields
- optional AuthResponse response = 2;
- optional AuthError error = 3;
-}
diff --git a/app/src/main/res/drawable/background_comment.xml b/app/src/main/res/drawable/background_comment.xml
new file mode 100644
index 00000000..152c90b9
--- /dev/null
+++ b/app/src/main/res/drawable/background_comment.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/background_pill_pred.xml b/app/src/main/res/drawable/background_pill_pred.xml
new file mode 100644
index 00000000..85ae3542
--- /dev/null
+++ b/app/src/main/res/drawable/background_pill_pred.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_chat_filled.xml b/app/src/main/res/drawable/ic_chat_filled.xml
new file mode 100644
index 00000000..dda8bf17
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chat_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fcast.xml b/app/src/main/res/drawable/ic_fcast.xml
new file mode 100644
index 00000000..22ac06f5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fcast.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_fcast_guide.xml b/app/src/main/res/layout/activity_fcast_guide.xml
new file mode 100644
index 00000000..4d6a2b89
--- /dev/null
+++ b/app/src/main/res/layout/activity_fcast_guide.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 747ad391..b0bf134b 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -1,89 +1,105 @@
-
-
+ android:layout_height="match_parent">
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:background="@color/black">
-
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingTop="20dp"
+ android:paddingBottom="15dp">
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+ android:layout_height="60dp" />
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+