From fca5fe38bb755afdd770a9f6f9bf8d6600ba37ed Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 27 May 2025 15:49:20 +0200 Subject: [PATCH 1/5] WIP Article/WEb --- .../platformplayer/activities/MainActivity.kt | 3 + .../media/models/article/IPlatformArticle.kt | 9 + .../models/article/IPlatformArticleDetails.kt | 12 + .../api/media/models/contents/ContentType.kt | 1 + .../media/platforms/js/models/IJSContent.kt | 4 +- .../media/platforms/js/models/JSArticle.kt | 39 ++ .../platforms/js/models/JSArticleDetails.kt | 14 +- .../api/media/platforms/js/models/JSWeb.kt | 31 + .../media/platforms/js/models/JSWebDetails.kt | 41 ++ .../engine/packages/PackageBridge.kt | 17 + .../main/ArticleDetailFragment.kt | 658 ++++++++++++++++++ .../mainactivity/main/WebDetailFragment.kt | 187 +++++ .../res/layout/fragview_article_detail.xml | 296 ++++++++ .../main/res/layout/fragview_web_detail.xml | 37 + 14 files changed, 1342 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt create mode 100644 app/src/main/res/layout/fragview_article_detail.xml create mode 100644 app/src/main/res/layout/fragview_web_detail.xml 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 095ac521..ef99642d 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -71,6 +71,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment +import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment @@ -150,6 +151,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //Frags Main lateinit var _fragMainHome: HomeFragment; lateinit var _fragPostDetail: PostDetailFragment; + lateinit var _fragWebDetail: WebDetailFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; @@ -324,6 +326,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); + _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt new file mode 100644 index 00000000..818f8a3b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +interface IPlatformArticle: IPlatformContent { + val summary: String?; + val thumbnails: Thumbnails?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt new file mode 100644 index 00000000..be7f816d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment + +interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails { + val segments: List; + val rating : IRating; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index d181c6da..736e9090 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -8,6 +8,7 @@ enum class ContentType(val value: Int) { POST(2), ARTICLE(3), PLAYLIST(4), + WEB(7), URL(9), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt index fd1f0894..777981bf 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent { ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj); - ContentType.CHANNEL -> JSChannelContent(config, obj) + ContentType.CHANNEL -> JSChannelContent(config, obj); + ContentType.ARTICLE -> JSArticle(config, obj); + ContentType.WEB -> JSWeb(config, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt new file mode 100644 index 00000000..679de602 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { + final override val contentType: ContentType get() = ContentType.POST; + + override val summary: String; + override val thumbnails: Thumbnails?; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformArticle"; + + summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; + thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt index 453c59d8..6ed30cc1 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt @@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent @@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.states.StateDeveloper -open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { +open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { final override val contentType: ContentType get() = ContentType.ARTICLE; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; - val rating: IRating; + override val rating: IRating; - val summary: String; - val thumbnails: Thumbnails?; - val segments: List; + override val summary: String; + override val thumbnails: Thumbnails?; + override val segments: List; constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { - val contextName = "PlatformPost"; + val contextName = "PlatformArticle"; rating = obj.getOrDefault(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); summary = _content.getOrThrow(client.config, "summary", contextName); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt new file mode 100644 index 00000000..9f68293f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWeb : JSContent, IPluginSourced { + final override val contentType: ContentType get() = ContentType.POST; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformWeb"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt new file mode 100644 index 00000000..02a274f4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails { + final override val contentType: ContentType get() = ContentType.WEB; + + val html: String?; + //TODO: Options? + + + constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { + val contextName = "PlatformWeb"; + + html = obj.getOrDefault(client.config, "html", contextName, null); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + +} diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index 12ea05af..d2d7cf04 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -77,6 +78,22 @@ class PackageBridge : V8Package { return "android"; } + @V8Property + fun supportedContent(): Array { + return arrayOf( + ContentType.MEDIA.value, + ContentType.POST.value, + ContentType.PLAYLIST.value, + ContentType.WEB.value, + ContentType.URL.value, + ContentType.NESTED_VIDEO.value, + ContentType.CHANNEL.value, + ContentType.LOCKED.value, + ContentType.PLACEHOLDER.value, + ContentType.DEFERRED.value + ) + } + @V8Function fun dispose(value: V8Value) { Logger.e(TAG, "Manual dispose: " + value.javaClass.name); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt new file mode 100644 index 00000000..c27bbe0c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt @@ -0,0 +1,658 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class ArticleDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: PostDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PostDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is IPlatformArticleDetails) { + _viewDetail?.clear(); + _viewDetail?.setArticleDetails(parameter); + } else if (parameter is IPlatformArticle) { + _viewDetail?.setArticleOverview(parameter); + } else if(parameter is String) { + _viewDetail?.setPostUrl(parameter); + } + } + + private class PostDetailView : ConstraintLayout { + private lateinit var _fragment: ArticleDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _article: IPlatformArticleDetails? = null; + private var _articleOverview: IPlatformArticle? = null; + private var _polycentricProfile: PolycentricProfile? = null; + private var _version = 0; + private var _isRepliesVisible: Boolean = false; + private var _repliesAnimator: ViewPropertyAnimator? = null; + + private val _creatorThumbnail: CreatorThumbnail; + private val _buttonSubscribe: SubscribeButton; + private val _channelName: TextView; + private val _channelMeta: TextView; + private val _textTitle: TextView; + private val _textMeta: TextView; + private val _containerSegments: LinearLayout; + private val _textContent: TextView; + private val _platformIndicator: PlatformIndicator; + private val _buttonShare: ImageButton; + + private val _layoutRating: LinearLayout; + private val _imageLikeIcon: ImageView; + private val _textLikes: TextView; + private val _imageDislikeIcon: ImageView; + private val _textDislikes: TextView; + + private val _addCommentView: AddCommentView; + + private val _rating: PillRatingLikesDislikes; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _imageActive: ImageView; + private val _layoutThumbnails: FlexboxLayout; + + private val _repliesOverlay: RepliesOverlay; + + private val _commentsList: CommentsList; + + private var _commentType: Boolean? = null; + private val _buttonPolycentric: Button + private val _buttonPlatform: Button + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is IPlatformArticleDetails) + throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); + return@TaskHandler result; + }) + .success { setArticleDetails(it) } + .exception { + Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); + } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; + + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_article_detail, this); + + val root = findViewById(R.id.root); + + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _channelName = findViewById(R.id.text_channel_name); + _channelMeta = findViewById(R.id.text_channel_meta); + _textTitle = findViewById(R.id.text_title); + _textMeta = findViewById(R.id.text_meta); + _containerSegments = findViewById(R.id.container_segments); + _platformIndicator = findViewById(R.id.platform_indicator); + _buttonShare = findViewById(R.id.button_share); + + + _layoutRating = findViewById(R.id.layout_rating); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _textLikes = findViewById(R.id.text_likes); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + _textDislikes = findViewById(R.id.text_dislikes); + + _commentsList = findViewById(R.id.comments_list); + _addCommentView = findViewById(R.id.add_comment_view); + + _rating = findViewById(R.id.rating); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _imageActive = findViewById(R.id.image_active); + _layoutThumbnails = findViewById(R.id.layout_thumbnails); + + _repliesOverlay = findViewById(R.id.replies_overlay); + + _buttonPolycentric = findViewById(R.id.button_polycentric) + _buttonPlatform = findViewById(R.id.button_platform) + + _textContent.setPlatformPlayerLinkMovementMethod(context); + + _buttonSubscribe.onSubscribed.subscribe { + //TODO: add overlay to layout + //UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); + }; + + val layoutTop: LinearLayout = findViewById(R.id.layout_top); + root.removeView(layoutTop); + _commentsList.setPrependedView(layoutTop); + + /*TODO: Why is this here? + _commentsList.onCommentsLoaded.subscribe { + updateCommentType(false); + };*/ + + _commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount " + context.getString(R.string.replies); + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + _repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + _commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + _repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); + } + + setRepliesOverlayVisible(isVisible = true, animate = true); + }; + + if (StatePolycentric.instance.enabled) { + _buttonPolycentric.setOnClickListener { + updateCommentType(false) + } + } else { + _buttonPolycentric.visibility = View.GONE + } + + _buttonPlatform.setOnClickListener { + updateCommentType(true) + } + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + }; + + _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); }; + + _buttonShare.setOnClickListener { share() }; + + _creatorThumbnail.onClick.subscribe { openChannel() }; + _channelName.setOnClickListener { openChannel() }; + _channelMeta.setOnClickListener { openChannel() }; + } + + private fun openChannel() { + val author = _article?.author ?: _articleOverview?.author ?: return; + _fragment.navigate(author); + } + + private fun share() { + try { + Logger.i(PreviewPostView.TAG, "sharePost") + + val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url; + _fragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; //TODO: Determine alt types? + }, null)); + } catch (e: Throwable) { + //Ignored + Logger.e(PreviewPostView.TAG, "Failed to share.", e); + } + } + + private fun updatePolycentricRating() { + _rating.visibility = View.GONE; + + val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return) + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + val version = _version; + + _rating.onLikeDislikeUpdated.remove(this); + + if (!StatePolycentric.instance.enabled) + return + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + if (version != _version) { + return@launch; + } + + try { + val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data)).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data)).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); + + if (version != _version) { + return@launch; + } + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + + withContext(Dispatchers.Main) { + if (version != _version) { + return@withContext; + } + + _rating.visibility = VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); + } + + 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(ref, args.hasLiked, args.hasDisliked) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; + } + } + } + + private fun setPlatformRating(rating: IRating?) { + if (rating == null) { + _layoutRating.visibility = View.GONE; + return; + } + + _layoutRating.visibility = View.VISIBLE; + + when (rating) { + is RatingLikeDislikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = rating.dislikes.toHumanNumber(); + } + is RatingLikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _textLikes.visibility = View.GONE; + _imageLikeIcon.visibility = View.GONE; + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + } + } + + fun applyFragment(frag: ArticleDetailFragment): PostDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _taskLoadPolycentricProfile.cancel(); + _version++; + + updateCommentType(null) + _url = null; + _article = null; + _articleOverview = null; + _creatorThumbnail.clear(); + //_buttonSubscribe.setSubscribeChannel(null); TODO: clear button + _channelName.text = ""; + setChannelMeta(null); + _textTitle.text = ""; + _textMeta.text = ""; + _textContent.text = ""; + setPlatformRating(null); + _polycentricProfile = null; + _rating.visibility = View.GONE; + updatePolycentricRating(); + setRepliesOverlayVisible(isVisible = false, animate = false); + + _addCommentView.setContext(null, null); + _platformIndicator.clearPlatform(); + } + + fun setArticleDetails(value: IPlatformArticleDetails) { + _url = value.url; + _article = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + setPlatformRating(value.rating); + + //Fetch only when not already called in setPostOverview + if (_articleOverview == null) { + fetchPolycentricProfile(); + updatePolycentricRating(); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + } + + val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1 + updateCommentType(commentType, true); + setLoading(false); + } + + fun setArticleOverview(value: IPlatformArticle) { + clear(); + _url = value.url; + _articleOverview = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + + updatePolycentricRating(); + fetchPolycentricProfile(); + fetchPost(); + } + + + private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) { + if (_isRepliesVisible == isVisible) { + return; + } + + _isRepliesVisible = isVisible; + _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 fetchPolycentricProfile() { + val author = _article?.author ?: _articleOverview?.author ?: return; + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(author.id); + } + + private fun setChannelMeta(value: IPlatformArticle?) { + val subscribers = value?.author?.subscribers; + if(subscribers != null && subscribers > 0) { + _channelMeta.visibility = View.VISIBLE; + _channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; + } else { + _channelMeta.visibility = View.GONE; + _channelMeta.text = ""; + } + } + + fun setPostUrl(url: String) { + clear(); + _url = url; + fetchPost(); + } + + fun onDestroy() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _repliesOverlay.cleanup(); + } + + private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _polycentricProfile = polycentricProfile; + + val pp = _polycentricProfile; + if (pp == null) { + _creatorThumbnail.setHarborAvailable(false, animate, null); + return; + } + + _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto()); + } + + private fun fetchPost() { + Logger.i(TAG, "fetchVideo") + _article = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + _article?.let { + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val post = _article; + val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) } + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + if (ref == null) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null") + _commentsList.clear(); + return + } + + _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); }; + } + + private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) { + val changed = commentType != _commentType + _commentType = commentType + + if (commentType == null) { + _buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac)) + } else { + _buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac)) + + if (commentType) { + _addCommentView.visibility = View.GONE; + + if (forceReload || changed) { + fetchComments(); + } + } else { + _addCommentView.visibility = View.VISIBLE; + + if (forceReload || changed) { + fetchPolycentricComments() + } + } + } + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "PostDetailFragment" + } + } + + companion object { + fun newInstance() = ArticleDetailFragment().apply {} + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt new file mode 100644 index 00000000..a3dd1dba --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt @@ -0,0 +1,187 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +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.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb +import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.ChannelTab +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class WebDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: WebDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = WebDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is JSWebDetails) { + _viewDetail?.clear(); + _viewDetail?.setWebDetails(parameter); + } + } + + private class WebDetailView : ConstraintLayout { + private lateinit var _fragment: WebDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _web: JSWebDetails? = null; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _webview: WebView; + + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_web_detail, this); + + val root = findViewById(R.id.root); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _webview = findViewById(R.id.webview); + _webview.webViewClient = object: WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url); + if(url != "about:blank") + setLoading(false); + } + } + } + + + fun applyFragment(frag: WebDetailFragment): WebDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _webview.loadUrl("about:blank"); + } + + fun setWebDetails(value: JSWebDetails) { + _web = value; + setLoading(true); + _webview.loadUrl("about:blank"); + if(!value.html.isNullOrEmpty()) + _webview.loadData(value.html, "text/html", null); + else + _webview.loadUrl(value.url ?: "about:blank"); + } + + fun onDestroy() { + _webview.loadUrl("about:blank"); + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "WebDetailFragment" + } + } + + companion object { + fun newInstance() = WebDetailFragment().apply {} + } +} diff --git a/app/src/main/res/layout/fragview_article_detail.xml b/app/src/main/res/layout/fragview_article_detail.xml new file mode 100644 index 00000000..16bade8d --- /dev/null +++ b/app/src/main/res/layout/fragview_article_detail.xml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +