diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0aa817af..0e48d174 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -32,7 +32,8 @@ let Type = { Text: { RAW: 0, HTML: 1, - MARKUP: 2 + MARKUP: 2, + CODE: 3 }, Chapter: { NORMAL: 0, @@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost { } } -class PlatformArticleDetails extends PlatformContent { +class PlatformWeb extends PlatformContent { + constructor(obj) { + super(obj, 7); + obj = obj ?? {}; + this.plugin_type = "PlatformWeb"; + } +} +class PlatformWebDetails extends PlatformWeb { + constructor(obj) { + super(obj, 7); + obj = obj ?? {}; + this.plugin_type = "PlatformWebDetails"; + this.html = obj.html; + } +} + +class PlatformArticle extends PlatformContent { + constructor(obj) { + super(obj, 3); + obj = obj ?? {}; + this.plugin_type = "PlatformArticle"; + this.rating = obj.rating ?? new RatingLikes(-1); + this.summary = obj.summary ?? ""; + this.thumbnails = obj.thumbnails ?? new Thumbnails([]); + } +} +class PlatformArticleDetails extends PlatformArticle { constructor(obj) { super(obj, 3); obj = obj ?? {}; this.plugin_type = "PlatformArticleDetails"; this.rating = obj.rating ?? new RatingLikes(-1); - this.summary = obj.summary ?? ""; this.segments = obj.segments ?? []; - this.thumbnails = obj.thumbnails ?? new Thumbnails([]); } } class ArticleSegment { @@ -315,9 +340,10 @@ class ArticleTextSegment extends ArticleSegment { } } class ArticleImagesSegment extends ArticleSegment { - constructor(images) { + constructor(images, caption) { super(2); this.images = images; + this.caption = caption; } } class ArticleNestedSegment extends ArticleSegment { 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 b051c904..fcb10008 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.isVisible @@ -45,6 +44,7 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment @@ -73,6 +73,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 @@ -152,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //Frags Main lateinit var _fragMainHome: HomeFragment; lateinit var _fragPostDetail: PostDetailFragment; + lateinit var _fragArticleDetail: ArticleDetailFragment; + lateinit var _fragWebDetail: WebDetailFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; @@ -330,6 +333,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); + _fragArticleDetail = ArticleDetailFragment.newInstance(); + _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); @@ -456,6 +461,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist.topBar = _fragTopBarNavigation; _fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation; + _fragArticleDetail.topBar = _fragTopBarNavigation; + _fragWebDetail.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation; _fragSourceDetail.topBar = _fragTopBarNavigation; @@ -855,7 +862,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return withContext(Dispatchers.IO) { Logger.i(TAG, "handleUrl(url=$url) on IO"); - if (StatePlatform.instance.hasEnabledVideoClient(url)) { + if (StatePlatform.instance.hasEnabledContentClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); withContext(Dispatchers.Main) { if (position > 0) @@ -1240,6 +1247,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { PlaylistFragment::class -> _fragMainPlaylist as T; RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; PostDetailFragment::class -> _fragPostDetail as T; + ArticleDetailFragment::class -> _fragArticleDetail as T; + WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; SourceDetailFragment::class -> _fragSourceDetail as T; 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/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt index c1de57d1..c59b7e1a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt @@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException enum class TextType(val value: Int) { RAW(0), HTML(1), - MARKUP(2); + MARKUP(2), + CODE(3); companion object { fun fromInt(value: Int): TextType 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/IJSContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt index 04382057..21b475ff 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt @@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent { ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.POST -> JSPostDetails(plugin.config, obj); ContentType.ARTICLE -> JSArticleDetails(plugin, obj); + ContentType.WEB -> JSWebDetails(plugin, 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..d1fb658e --- /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.ARTICLE; + + 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..49f6fbeb --- /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.WEB; + + 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..0f210d8d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt @@ -0,0 +1,785 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.Html +import android.text.method.ScrollingMovementMethod +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.core.view.isVisible +import androidx.core.view.setPadding +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.UISlideOverlays +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.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +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.post.TextType +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.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails +import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment +import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment +import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment +import com.futo.platformplayer.api.media.platforms.js.models.SegmentType +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.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView +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: ArticleDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ArticleDetailView(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 ArticleDetailView : 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 _textSummary: TextView; + private val _containerSegments: LinearLayout; + 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 var _overlayContainer: FrameLayout + 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); + _textSummary = findViewById(R.id.text_summary); + _containerSegments = findViewById(R.id.container_segments); + _platformIndicator = findViewById(R.id.platform_indicator); + _buttonShare = findViewById(R.id.button_share); + + _overlayContainer = findViewById(R.id.overlay_container); + + _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); + + + _repliesOverlay = findViewById(R.id.replies_overlay); + + _buttonPolycentric = findViewById(R.id.button_polycentric) + _buttonPlatform = findViewById(R.id.button_platform) + + _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): ArticleDetailView { + _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 = ""; + setPlatformRating(null); + _polycentricProfile = null; + _rating.visibility = View.GONE; + updatePolycentricRating(); + setRepliesOverlayVisible(isVisible = false, animate = false); + + _containerSegments.removeAllViews(); + + _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? + + _textSummary.text = value.summary + _textSummary.isVisible = !value.summary.isNullOrEmpty() + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + setPlatformRating(value.rating); + + for(seg in value.segments) { + when(seg.type) { + SegmentType.TEXT -> { + if(seg is JSTextSegment) { + _containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType)) + } + } + SegmentType.IMAGES -> { + if(seg is JSImagesSegment) { + if(seg.images.size > 0) + _containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption)) + } + } + SegmentType.NESTED -> { + if(seg is JSNestedSegment) { + _containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer)); + } + } + else ->{} + } + } + + //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() + } + } + + class ArticleTextBlock : LinearLayout { + constructor(context: Context?, content: String, textType: TextType) : super(context){ + inflate(context, R.layout.view_segment_text, this); + + findViewById(R.id.text_content)?.let { + if(textType == TextType.HTML) + it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT); + else if(textType == TextType.CODE) { + it.text = content; + it.setPadding(15.dp(resources)); + it.setHorizontallyScrolling(true); + it.movementMethod = ScrollingMovementMethod(); + it.setTypeface(Typeface.MONOSPACE); + it.setBackgroundResource(R.drawable.background_videodetail_description) + } + else + it.text = content; + } + } + } + class ArticleImageBlock: LinearLayout { + constructor(context: Context?, image: String, caption: String? = null) : super(context){ + inflate(context, R.layout.view_segment_image, this); + + findViewById(R.id.image_content)?.let { + Glide.with(it) + .load(image) + .crossfade() + .into(it); + } + findViewById(R.id.text_content)?.let { + if(caption?.isNullOrEmpty() == true) + it.isVisible = false; + else + it.text = caption; + } + } + } + class ArticleContentBlock: LinearLayout { + constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) { + if(content != null) { + var view: View? = null; + if(content is IPlatformNestedContent) { + view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null); + view.bind(content); + view.onContentUrlClicked.subscribe { a,b -> } + } + else if(content is IPlatformVideo) { + view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true); + view.bind(content); + view.onVideoClicked.subscribe { a,b -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + if(overlayContainer != null) { + view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) }; + } + view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) } + view.onAddToWatchLaterClicked.subscribe { a -> + if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) + UIDialogs.toast("Added to watch later\n[${content.name}]") + } + } + else if(content is IPlatformPost) { + view = PreviewPostView(context, FeedStyle.THUMBNAIL); + view.bind(content); + view.onContentClicked.subscribe { a -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + } + else if(content is IPlatformArticle) { + view = PreviewPostView(context, FeedStyle.THUMBNAIL); + view.bind(content); + view.onContentClicked.subscribe { a -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + } + else if(content is IPlatformLockedContent) { + view = PreviewLockedView(context, FeedStyle.THUMBNAIL); + view.bind(content); + } + if(view != null) + addView(view); + } + } + } + + + 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/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index 76103d4a..cc528a2b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -10,12 +10,14 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.article.IPlatformArticle import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta @@ -196,7 +198,14 @@ abstract class ContentFeedView : FeedView(content); } else if (content is IPlatformPost) { fragment.navigate(content); + } else if(content is IPlatformArticle) { + fragment.navigate(content); } + else if(content is JSWeb) { + fragment.navigate(content); + } + else + UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]"); } protected open fun onContentUrlClicked(url: String, contentType: ContentType) { when(contentType) { 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..8aa1eec2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt @@ -0,0 +1,223 @@ +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.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.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 JSWeb) { + _viewDetail?.clear(); + _viewDetail?.setWeb(parameter); + } + 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; + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is JSWebDetails) + throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); + return@TaskHandler result; + }) + .success { setWebDetails(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 }; + + + 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 setWeb(value: JSWeb) { + _url = value.url; + setLoading(true); + clear(); + fetchPost(); + } + 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"); + } + + private fun fetchPost() { + Logger.i(WebDetailView.TAG, "fetchWeb") + _web = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + 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/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 7833b781..c843ea9f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -5,7 +5,6 @@ import androidx.collection.LruCache import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.PlatformMultiClientPool @@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage -import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.ToastView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okhttp3.internal.concat import java.lang.Thread.sleep import java.time.OffsetDateTime import kotlin.streams.asSequence @@ -669,7 +666,7 @@ class StatePlatform { //Video - fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; + fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url) ?: throw NoPlatformClientException("No client enabled that supports this content url (${url})"); fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 35dd2d7b..327497fb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader createPlaceholderViewHolder(viewGroup); ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup); + ContentType.ARTICLE -> createPostViewHolder(viewGroup); + ContentType.WEB -> createPostViewHolder(viewGroup); ContentType.POST -> createPostViewHolder(viewGroup); ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup); ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt index 1d90e09c..6c2b09cf 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt @@ -21,9 +21,11 @@ import com.futo.platformplayer.R import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp @@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout { content.content else "" + } else if(content is IPlatformArticle) { + if(!content.summary.isNullOrEmpty()) + content.summary ?: "" + else + "" + } else if(content is JSWeb) { + if(!content.url.isNullOrEmpty()) + "WEB:" + content.url + else + "" } else ""; if (content.name.isNullOrEmpty()) { @@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout { if (content is IPlatformPost) { setImages(content.thumbnails.filterNotNull()); - } else { + } + else if(content is IPlatformArticle) { + if(content.thumbnails != null) + setImages(listOf(content.thumbnails!!)); + else + setImages(null); + } + else { setImages(null); } 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..9476c0de --- /dev/null +++ b/app/src/main/res/layout/fragview_article_detail.xml @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +