diff --git a/app/build.gradle b/app/build.gradle index 278e8b0f..25d458d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,6 +156,7 @@ android { dependencies { implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' + implementation 'com.google.android.material:material:1.12.0' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index c2e7abb8..7f348347 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -785,6 +785,7 @@ let plugin = { //To override by plugin const source = { getHome() { return new ContentPager([], false, {}); }, + getShorts() { return new VideoPager([], false, {}); }, enable(config){ }, disable() {}, 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 073033da..0d5bf8d9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -62,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -169,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; + lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -338,6 +340,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); + _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -1253,6 +1256,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; + ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 010fd3c1..56d8fbd2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.models.ImageVariable @@ -36,6 +37,11 @@ interface IPlatformClient { */ fun getHome(): IPager + /** + * Gets the shorts feed + */ + fun getShorts(): IPager + //Search /** * Gets search suggestion for the provided query string diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 8c4097ae..fd3c9dde 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter @@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager +import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 @@ -124,6 +126,7 @@ open class JSClient : IPlatformClient { val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true + val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true fun getSubscriptionRateLimit(): Int? { val pluginRateLimit = config.subscriptionRateLimit; @@ -328,6 +331,13 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getHome()")); } + @JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform") + override fun getShorts(): IPager = isBusyWith("getShorts") { + ensureEnabled() + return@isBusyWith JSVideoPager(config, this, + plugin.executeTyped("source.getShorts()")) + } + @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index e318b5c2..8d5675b6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -48,6 +48,7 @@ class SourcePluginConfig( var subscriptionRateLimit: Int? = null, var enableInSearch: Boolean = true, var enableInHome: Boolean = true, + var enableInShorts: Boolean = true, var supportedClaimTypes: List = listOf(), var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index add53131..971c7baa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -103,9 +103,11 @@ class SourcePluginDescriptor { @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) var enableHome: Boolean? = null; - @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) var enableSearch: Boolean? = null; + + @FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3) + var enableShorts: Boolean? = null; } @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) @@ -143,6 +145,8 @@ class SourcePluginDescriptor { tabEnabled.enableHome = config.enableInHome if(tabEnabled.enableSearch == null) tabEnabled.enableSearch = config.enableInSearch + if(tabEnabled.enableShorts == null) + tabEnabled.enableShorts = config.enableInShorts } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index e86c9ba6..66554572 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -7,12 +7,12 @@ import java.util.stream.IntStream * A Content MultiPager that returns results based on a specified distribution * TODO: Merge all basic distribution pagers */ -class MultiDistributionContentPager : MultiPager { +class MultiDistributionContentPager : MultiPager { - private val dist : HashMap, Float>; - private val distConsumed : HashMap, Float>; + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { val distTotal = pagers.values.sum(); dist = HashMap(); @@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager { } @Synchronized - override fun selectItemIndex(options: Array>): Int { + override fun selectItemIndex(options: Array>): Int { if(options.size == 0) return -1; var bestIndex = 0; @@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager { distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; return bestIndex; } - - } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 0939fbde..b99e211e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException @@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _query: String? = null private var _searchView: SearchView? = null - val onContentClicked = Event2(); + val onContentClicked = Event3, ArrayList>?>(); val onContentUrlClicked = Event2(); val onUrlClicked = Event1(); val onChannelClicked = Event1(); @@ -211,7 +212,10 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); - this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); + this.onContentClicked.subscribe { content, num -> + val results = ArrayList(_results) + this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results)) + } this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index c8f0d62e..f6e57c26 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.* import androidx.core.animation.doOnEnd import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() { fun newInstance() = MenuBottomBarFragment().apply { } + @UnstableApi //Add configurable buttons here var buttonDefinitions = listOf( ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { @@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() { ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate(withHistory = false) }), ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { - val c = it.context ?: return@ButtonDefinition; + val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); it.requireFragment().preventPictureInPicture(); val intent = Intent(c, SettingsActivity::class.java); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 4cd8455c..91e6aaa3 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() { } } } + adapter.onShortClicked.subscribe { v, _, pagerPair -> + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(Triple(v, pagerPair!!.first, pagerPair.second)) + } + } + } adapter.onAddToClicked.subscribe { content -> _overlayContainer.let { if (content is IPlatformVideo) _slideUpOverlay = diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt new file mode 100644 index 00000000..f70d9104 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -0,0 +1,1268 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.Spanned +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.dp +import com.futo.platformplayer.engine.exceptions.ScriptAgeException +import com.futo.platformplayer.engine.exceptions.ScriptException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateMeta +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanBytesSize +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.MonetizationView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.DescriptionOverlay +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.overlays.SupportOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle +import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.video.FutoShortPlayer +import com.futo.platformplayer.views.video.FutoVideoPlayerBase +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.futo.polycentric.core.toURLInfoSystemLinkUrl +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import userpackage.Protocol + +@UnstableApi +class ShortView : FrameLayout { + private lateinit var mainFragment: MainFragment + private val player: FutoShortPlayer + + private val channelInfo: LinearLayout + private val creatorThumbnail: CreatorThumbnail + private val channelName: TextView + private val videoTitle: TextView + private val platformIndicator: PlatformIndicator + + private val backButton: MaterialButton + private val backButtonContainer: ConstraintLayout + + private val likeContainer: FrameLayout + private val dislikeContainer: FrameLayout + private val likeButton: MaterialButton + private val likeCount: TextView + private val dislikeButton: MaterialButton + private val dislikeCount: TextView + + private val commentsButton: MaterialButton + private val shareButton: MaterialButton + private val refreshButton: MaterialButton + private val refreshButtonContainer: View + private val qualityButton: MaterialButton + + private val playPauseOverlay: FrameLayout + private val playPauseIcon: ImageView + + private val overlayLoading: FrameLayout + private val overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + + private var overlayQualitySelector: SlideUpMenuOverlay? = null + + private var video: IPlatformVideo? = null + set(value) { + field = value + onVideoUpdated.emit(value) + } + private var videoDetails: IPlatformVideoDetails? = null + + private var playWhenReady = false + + private var _lastVideoSource: IVideoSource? = null + private var _lastAudioSource: IAudioSource? = null + private var _lastSubtitleSource: ISubtitleSource? = null + + private var loadVideoTask: TaskHandler? = null + private var loadLikesTask: TaskHandler>? = + null + + val onResetTriggered = Event0() + private val onPlayingToggled = Event1() + private val onLikesLoaded = Event3() + private val onLikeDislikeUpdated = Event1() + private val onVideoUpdated = Event1() + + private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() + + var likes: Long = 0 + set(value) { + field = value + likeCount.text = value.toString() + } + + var dislikes: Long = 0 + set(value) { + field = value + dislikeCount.text = value.toString() + } + + constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { + this.overlayQualityContainer = overlayQualityContainer + + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT + ) + + this.mainFragment = fragment + bottomSheet.mainFragment = fragment + } + + // Required constructor for XML inflation + constructor(context: Context) : this(context, null, null) + + // Required constructor for XML inflation with attributes + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null) + + // Required constructor for XML inflation with attributes and style + constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super( + context, attrs, defStyleAttr ?: 0 + ) { + // Inflate the layout once here + inflate(context, R.layout.view_short, this) + + // Initialize all val properties using findViewById + player = findViewById(R.id.short_player) + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + platformIndicator = findViewById(R.id.short_platform_indicator) + backButton = findViewById(R.id.back_button) + backButtonContainer = findViewById(R.id.back_button_container) + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + refreshButtonContainer = findViewById(R.id.refresh_button_container) + qualityButton = findViewById(R.id.quality_button) + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + player.setOnClickListener { + if (player.activelyPlaying) { + player.pause() + onPlayingToggled.emit(false) + } else { + player.play() + onPlayingToggled.emit(true) + } + } + + onPlayingToggled.subscribe { playing -> + if (playing) { + playPauseIcon.setImageResource(R.drawable.ic_play) + playPauseIcon.contentDescription = context.getString(R.string.play) + } else { + playPauseIcon.setImageResource(R.drawable.ic_pause) + playPauseIcon.contentDescription = context.getString(R.string.pause) + } + showPlayPauseIcon() + } + + onVideoUpdated.subscribe { + videoTitle.text = it?.name + platformIndicator.setPlatformFromClientID(it?.id?.pluginId) + creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) + channelName.text = it?.author?.name + } + + backButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.closeSegment() + } + + channelInfo.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.navigate(video?.author) + } + + videoTitle.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + commentsButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + shareButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val url = video?.shareUrl ?: video?.url + mainFragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + }, null)) + } + + refreshButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + onResetTriggered.emit() + } + + refreshButton.setOnLongClickListener { + UIDialogs.toast(context, "Reload all platform shorts pagers") + false + } + + qualityButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + showVideoSettings() + } + + likeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !likeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + likes++ + } else { + likes-- + } + + likeButton.isChecked = checked + + if (dislikeButton.isChecked && checked) { + dislikeButton.isChecked = false + dislikes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + dislikeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !dislikeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + dislikes++ + } else { + dislikes-- + } + + dislikeButton.isChecked = checked + + if (likeButton.isChecked && checked) { + likeButton.isChecked = false + likes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + onLikesLoaded.subscribe(tag) { rating, liked, disliked -> + likes = rating.likes + dislikes = rating.dislikes + likeButton.isChecked = liked + dislikeButton.isChecked = disliked + + dislikeContainer.visibility = VISIBLE + likeContainer.visibility = VISIBLE + } + + player.onPlaybackStateChanged.subscribe { + val videoSource = _lastVideoSource + + if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + val audioTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } + + val videoTrackFormats = mutableListOf() + val audioTrackFormats = mutableListOf() + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)) + } + if (audioTracks != null) { + for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)) + } + + updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height } + .sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate } + .sortedBy { it.bitrate }) + } else { + updateQualitySourcesOverlay(videoDetails, null) + } + } + } + + private fun showPlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.alpha = 0f + overlay.scaleX = 0f + overlay.scaleY = 0f + overlay.visibility = VISIBLE + + overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400) + .setInterpolator(OvershootInterpolator(1.2f)).start() + + overlay.postDelayed({ + hidePlayPauseIcon() + }, 1500) + } + + private fun hidePlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300) + .setInterpolator(AccelerateInterpolator()).withEndAction { + overlay.visibility = GONE + }.start() + } + + // TODO merge this with the updateQualitySourcesOverlay for the normal video player + @androidx.annotation.OptIn(UnstableApi::class) + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + Logger.i(TAG, "updateQualitySourcesOverlay") + + val video: IPlatformVideoDetails? + val localVideoSources: List? + val localAudioSource: List? + val localSubtitleSources: List? + + val videoSources: List? + val audioSources: List? + + if (videoDetails is VideoLocal) { + video = videoLocal?.videoSerialized + localVideoSources = videoDetails.videoSource.toList() + localAudioSource = videoDetails.audioSource.toList() + localSubtitleSources = videoDetails.subtitlesSources.toList() + videoSources = null + audioSources = null + } else { + video = videoDetails + videoSources = video?.video?.videoSources?.toList() + audioSources = + if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() + else null + if (videoLocal != null) { + localVideoSources = videoLocal.videoSource.toList() + localAudioSource = videoLocal.audioSource.toList() + localSubtitleSources = videoLocal.subtitlesSources.toList() + } else { + localVideoSources = null + localAudioSource = null + localSubtitleSources = null + } + } + + val doDedup = Settings.instance.playback.simplifySources + + val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() + ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } + ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() + ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() + val bestAudioContainer = + audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container } + val bestAudioSources = + if (doDedup) audioSources?.filter { it.container == bestAudioContainer } + ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) + ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf() + + val canSetSpeed = true + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector = + SlideUpMenuOverlay( + this.context, overlayQualityContainer, context.getString( + R.string.quality + ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString()) + onClick.subscribe { v -> + + player.setPlaybackRate(v.toFloat()) + setSelected(v) + + } + } else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_video), "video", (listOf( + SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) + ) + (liveStreamVideoFormats.map { + SlideUpMenuItem( + this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType + ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }) + })) + ) + else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }) + }.toList().toTypedArray() + ) + else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.video), "video", *bestVideoSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null + ) + } + + private fun handleSelectVideoTrack(videoSource: IVideoSource) { + Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") + if (_lastVideoSource == videoSource) return + + _lastVideoSource = videoSource + + playVideo(player.position) + } + + private fun handleSelectAudioTrack(audioSource: IAudioSource) { + Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") + if (_lastAudioSource == audioSource) return + + _lastAudioSource = audioSource + + playVideo(player.position) + } + + private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { + Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") + var toSet: ISubtitleSource? = subtitleSource + if (_lastSubtitleSource == subtitleSource) toSet = null + + player.swapSubtitles(mainFragment.lifecycleScope, toSet) + + _lastSubtitleSource = toSet + } + + private fun showVideoSettings() { + Logger.i(TAG, "showVideoSettings") + + overlayQualitySelector?.selectOption("video", _lastVideoSource) + overlayQualitySelector?.selectOption("audio", _lastAudioSource) + overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource) + + if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + + var selectedQuality: Format? = null + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) { + if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) { + selectedQuality = videoTracks.mediaTrackGroup.getFormat(i) + } + } + } + + var videoMenuGroup: SlideUpMenuGroup? = null + for (view in overlayQualitySelector!!.groupItems) { + if (view is SlideUpMenuGroup && view.groupTag == "video") { + videoMenuGroup = view + } + } + + if (selectedQuality != null) { + videoMenuGroup?.getItem("auto")?.setSubText("") + overlayQualitySelector?.selectOption("video", selectedQuality) + } else { + videoMenuGroup?.getItem("auto") + ?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}") + overlayQualitySelector?.selectOption("video", "auto") + } + } + + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } + ?.let { + (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) + } + + overlayQualitySelector?.show() + } + + @Suppress("unused") + fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { + this.mainFragment = fragment + this.bottomSheet.mainFragment = fragment + this.overlayQualityContainer = overlayQualityContainer + } + + fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) { + if (this.video?.url == video.url) { + return + } + this.video = video + + refreshButtonContainer.visibility = if (isChannelShortsMode) { + GONE + } else { + VISIBLE + } + backButtonContainer.visibility = if (isChannelShortsMode) { + VISIBLE + } else { + GONE + } + + loadVideo(video.url) + } + + @Suppress("unused") + fun changeVideo(videoDetails: IPlatformVideoDetails) { + if (video?.url == videoDetails.url) { + return + } + + this.video = videoDetails + this.videoDetails = videoDetails + } + + fun play() { + loadLikes(this.video!!) + player.clear() + player.attach() + player.clear() + playVideo() + } + + fun pause() { + player.pause() + } + + fun stop() { + playWhenReady = false + + player.clear() + player.detach() + } + + fun cancel() { + loadVideoTask?.cancel() + loadLikesTask?.cancel() + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = VISIBLE + } else { + overlayLoading.visibility = GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + private fun loadLikes(video: IPlatformVideo) { + likeContainer.visibility = GONE + dislikeContainer.visibility = GONE + + loadLikesTask?.cancel() + loadLikesTask = + TaskHandler>( + StateApp.instance.scopeGetter, { + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = + video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + 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) + ) + + Pair(ref, queryReferencesResponse) + }).success { (ref, queryReferencesResponse) -> + val likes = queryReferencesResponse.countsList[0] + val dislikes = queryReferencesResponse.countsList[1] + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) + onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) + 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) + } + + mainFragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") + args.processHandle.fullyBackfillServersAnnounceExceptions() + Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") + } catch (e: Throwable) { + Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, args.hasLiked, args.hasDisliked + ) + } + } + + loadLikesTask?.run(video) + } + + private fun loadVideo(url: String) { + loadVideoTask?.cancel() + videoDetails = null + _lastVideoSource = null + _lastAudioSource = null + _lastSubtitleSource = null + + setLoading(true) + + loadVideoTask = TaskHandler( + StateApp.instance.scopeGetter, { + val result = StatePlatform.instance.getContentDetails(it).await() + if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") + return@TaskHandler result + }).success { result -> + videoDetails = result + video = result + + bottomSheet.video = result + + setLoading(false) + + if (playWhenReady) playVideo() + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { e -> + Logger.w(TAG, "exception", e) + UIDialogs.showDialog( + context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { + val id = e.config.let { if (it is SourcePluginConfig) it.id else null } + val didLogin = + if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { + loadVideo(url) + } + if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") + }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(ChannelFragment.TAG, "Failed to load video.", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) + } + + loadVideoTask?.run(url) + } + + private fun playVideo(resumePositionMs: Long = 0) { + val videoDetails = this@ShortView.videoDetails + + if (videoDetails === null) { + playWhenReady = true + return + } + + updateQualitySourcesOverlay(videoDetails, null) + + try { + val videoSource = _lastVideoSource + ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + val audioSource = _lastAudioSource + ?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context)) + val subtitleSource = _lastSubtitleSource + ?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null) + Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") + + if (videoSource == null && audioSource == null) { + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + StatePlatform.instance.clearContentDetailCache(videoDetails.url) + return + } + + val thumbnail = videoDetails.thumbnails.getHQThumbnail() + if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() + .load(thumbnail).into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + player.setArtwork(resource.toDrawable(resources)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + player.setArtwork(null) + } + }) + else player.setArtwork(null) + player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) + if (subtitleSource != null) player.swapSubtitles(mainFragment.lifecycleScope, subtitleSource) + player.seekTo(resumePositionMs) + + _lastVideoSource = videoSource + _lastAudioSource = audioSource + _lastSubtitleSource = subtitleSource + } catch (ex: UnsupportedCastException) { + Logger.e(TAG, "Failed to load cast media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) + } + } + + companion object { + const val TAG = "VideoDetailView" + } + + class CommentsModalBottomSheet : BottomSheetDialogFragment() { + var mainFragment: MainFragment? = null + + private lateinit var containerContent: FrameLayout + private lateinit var containerContentMain: LinearLayout + private lateinit var containerContentReplies: RepliesOverlay + private lateinit var containerContentDescription: DescriptionOverlay + private lateinit var containerContentSupport: SupportOverlay + + private lateinit var title: TextView + private lateinit var subTitle: TextView + private lateinit var channelName: TextView + private lateinit var channelMeta: TextView + private lateinit var creatorThumbnail: CreatorThumbnail + private lateinit var channelButton: LinearLayout + private lateinit var monetization: MonetizationView + private lateinit var platform: PlatformIndicator + private lateinit var textLikes: TextView + private lateinit var textDislikes: TextView + private lateinit var layoutRating: LinearLayout + private lateinit var imageDislikeIcon: ImageView + private lateinit var imageLikeIcon: ImageView + + private lateinit var description: TextView + private lateinit var descriptionContainer: LinearLayout + private lateinit var descriptionViewMore: TextView + + private lateinit var commentsList: CommentsList + private lateinit var addCommentView: AddCommentView + + private var polycentricProfile: PolycentricProfile? = null + + private lateinit var buttonPolycentric: Button + private lateinit var buttonPlatform: Button + + private var tabIndex: Int? = null + + private var contentOverlayView: View? = null + + lateinit var video: IPlatformVideoDetails + + private lateinit var behavior: BottomSheetBehavior + + private val _taskLoadPolycentricProfile = + TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it) + } + + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = + BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + behavior = bottomSheetDialog.behavior + + // TODO figure out how to not need all of these non null assertions + containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! + containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! + containerContentReplies = + bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! + containerContentDescription = + bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! + containerContentSupport = + bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! + + title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! + subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! + channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! + channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! + creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! + channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! + monetization = bottomSheetDialog.findViewById(R.id.monetization)!! + platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! + layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! + textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! + textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! + imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! + imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! + + description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! + descriptionContainer = + bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! + descriptionViewMore = + bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! + + addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! + commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! + buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! + buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! + + commentsList.onAuthorClick.subscribe { c -> + if (c !is PolycentricPlatformComment) { + return@subscribe + } + val id = c.author.id.value + + Logger.i(TAG, "onAuthorClick: $id") + if (id != null && id.startsWith("polycentric://")) { + val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) + mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) + } + } + commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0 + var metadata = "" + if (replyCount > 0) { + metadata += "$replyCount " + requireContext().getString(R.string.replies) + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c + containerContentReplies.load(tabIndex!! != 0, 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 { + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) + } + animateOpenOverlayView(containerContentReplies) + } + + if (StatePolycentric.instance.enabled) { + buttonPolycentric.setOnClickListener { + setTabIndex(0) + StateMeta.instance.setLastCommentSection(0) + } + } else { + buttonPolycentric.visibility = GONE + } + + buttonPlatform.setOnClickListener { + setTabIndex(1) + StateMeta.instance.setLastCommentSection(1) + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + addCommentView.setContext(video.url, ref) + + if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { + setTabIndex(2, true) + } else { + when (Settings.instance.comments.defaultCommentSection) { + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) + 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) + } + } + + containerContentDescription.onClose.subscribe { animateCloseOverlayView() } + containerContentReplies.onClose.subscribe { animateCloseOverlayView() } + + descriptionViewMore.setOnClickListener { + animateOpenOverlayView(containerContentDescription) + } + + updateDescriptionUI(video.description.fixHtmlLinks()) + + val dp5 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) + val dp2 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) + + //UI + title.text = video.name + channelName.text = video.author.name + if (video.author.subscribers != null) { + channelMeta.text = if ((video.author.subscribers + ?: 0) > 0 + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" + (channelName.layoutParams as MarginLayoutParams).setMargins( + 0, (dp5 * -1).toInt(), 0, 0 + ) + } else { + channelMeta.text = "" + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) + } + + video.author.let { + if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) + else monetization.setPlatformMembership(null, null) + } + + val subTitleSegments: ArrayList = ArrayList() + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") + if (video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0 + val ago = video.datetime?.toHumanNowDiffString(true) + if (diff >= 0) subTitleSegments.add("$ago ago") + else subTitleSegments.add("available in $ago") + } + + platform.setPlatformFromClientID(video.id.pluginId) + subTitle.text = subTitleSegments.joinToString(" • ") + creatorThumbnail.setThumbnail(video.author.thumbnail, false) + + setPolycentricProfile(null, animate = false) + _taskLoadPolycentricProfile.run(video.author.id) + + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = VISIBLE + textDislikes.visibility = VISIBLE + textDislikes.text = r.dislikes.toHumanNumber() + } + + is RatingLikes -> { + val r = video.rating as RatingLikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = GONE + textDislikes.visibility = GONE + } + + else -> { + layoutRating.visibility = GONE + } + } + + monetization.onSupportTap.subscribe { + containerContentSupport.setPolycentricProfile(polycentricProfile) + animateOpenOverlayView(containerContentSupport) + } + + monetization.onStoreTap.subscribe { + polycentricProfile?.systemState?.store?.let { + try { + val uri = it.toUri() + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + requireContext().startActivity(intent) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e) + } + } + } + monetization.onUrlTap.subscribe { + mainFragment!!.navigate(it) + } + + addCommentView.onCommentAdded.subscribe { + commentsList.addComment(it) + } + + channelButton.setOnClickListener { + mainFragment!!.navigate(video.author) + } + + return bottomSheetDialog + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + animateCloseOverlayView() + } + + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + polycentricProfile = profile + + val dp35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } + + if (avatar != null) { + creatorThumbnail.setThumbnail(avatar, animate) + } else { + creatorThumbnail.setThumbnail(video.author.thumbnail, animate) + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) + } + + val username = profile?.systemState?.username + if (username != null) { + channelName.text = username + } + + monetization.setPolycentricProfile(profile) + } + + private fun setTabIndex(index: Int?, forceReload: Boolean = false) { + Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") + val changed = tabIndex != index || forceReload + if (!changed) { + return + } + + tabIndex = index + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) + + when (index) { + null -> { + addCommentView.visibility = GONE + commentsList.clear() + } + + 0 -> { + addCommentView.visibility = VISIBLE + fetchPolycentricComments() + } + + 1 -> { + addCommentView.visibility = GONE + fetchComments() + } + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video.let { + commentsList.load(true) { StatePlatform.instance.getComments(it) } + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video + val idValue = video.id.value + if (video.url.isEmpty()) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was null") + commentsList.clear() + return + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } + } + + private fun updateDescriptionUI(text: Spanned) { + containerContentDescription.load(text) + description.text = text + + if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE + else descriptionContainer.visibility = GONE + } + + private fun animateOpenOverlayView(view: View) { + if (contentOverlayView != null) { + Logger.e(TAG, "Content overlay already open") + return + } + + behavior.isDraggable = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + val animHeight = containerContentMain.height + + view.translationY = animHeight.toFloat() + view.visibility = VISIBLE + + view.animate().setDuration(300).translationY(0f).withEndAction { + contentOverlayView = view + }.start() + } + + private fun animateCloseOverlayView() { + val curView = contentOverlayView + if (curView == null) { + Logger.e(TAG, "No content overlay open") + return + } + + behavior.isDraggable = true + + val animHeight = contentOverlayView!!.height + + curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { + curView.visibility = GONE + contentOverlayView = null + }.start() + } + + companion object { + const val TAG = "ModalBottomSheet" + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt new file mode 100644 index 00000000..63937bc6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -0,0 +1,363 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.buttons.BigButton + +@UnstableApi +class ShortsFragment : MainFragment() { + override val isMainView: Boolean = true + override val isTab: Boolean = true + override val hasBottomBar: Boolean get() = true + + private var loadPagerTask: TaskHandler>? = null + private var nextPageTask: TaskHandler>? = null + + private var mainShortsPager: IPager? = null + private val mainShorts: MutableList = mutableListOf() + + // the pager to call next on + private var currentShortsPager: IPager? = null + + // the shorts array bound to the ViewPager2 adapter + private val currentShorts: MutableList = mutableListOf() + + private var channelShortsPager: IPager? = null + private val channelShorts: MutableList = mutableListOf() + val isChannelShortsMode: Boolean + get() = channelShortsPager != null + + private var viewPager: ViewPager2? = null + private lateinit var zeroState: LinearLayout + private lateinit var sourcesButton: BigButton + private lateinit var overlayLoading: FrameLayout + private lateinit var overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + private var customViewAdapter: CustomViewAdapter? = null + + init { + loadPager() + } + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + (activity as MainActivity?)?.getFragment()?.closeVideoDetails() + super.onShownWithView(parameter, isBack) + + if (parameter is Triple<*, *, *>) { + setLoading(false) + channelShorts.clear() + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShorts.addAll(parameter.third as ArrayList) + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShortsPager = parameter.second as IPager + + currentShorts.clear() + currentShorts.addAll(channelShorts) + currentShortsPager = channelShortsPager + + viewPager?.adapter?.notifyDataSetChanged() + + viewPager?.post { + viewPager?.currentItem = channelShorts.indexOfFirst { + return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id + } + } + } else if (isChannelShortsMode) { + channelShortsPager = null + channelShorts.clear() + currentShorts.clear() + + if (loadPagerTask == null) { + currentShorts.addAll(mainShorts) + currentShortsPager = mainShortsPager + } else { + setLoading(true) + } + + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + updateZeroState() + } + + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_shorts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewPager = view.findViewById(R.id.view_pager) + zeroState = view.findViewById(R.id.zero_state) + sourcesButton = view.findViewById(R.id.sources_button) + overlayLoading = view.findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) + overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) + + sourcesButton.onClick.subscribe { + sourcesButton.playSoundEffect(SoundEffectConstants.CLICK) + navigate() + } + + setLoading(true) + + Logger.i(TAG, "Creating adapter") + val customViewAdapter = + CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) { + if (!currentShortsPager!!.hasMorePages()) { + return@CustomViewAdapter + } + nextPage() + } + customViewAdapter.onResetTriggered.subscribe { + setLoading(true) + loadPager() + + loadPagerTask!!.success { + setLoading(false) + } + } + val viewPager = viewPager!! + viewPager.adapter = customViewAdapter + + this.customViewAdapter = customViewAdapter + + if (loadPagerTask == null && currentShorts.isEmpty()) { + loadPager() + + loadPagerTask!!.success { + setLoading(false) + updateZeroState() + } + } else { + setLoading(false) + updateZeroState() + } + + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + fun play(adapter: CustomViewAdapter, position: Int) { + val recycler = (viewPager.getChildAt(0) as RecyclerView) + val viewHolder = + recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? + + if (viewHolder == null) { + adapter.needToPlay = position + } else { + val focusedView = viewHolder.shortView + focusedView.play() + adapter.previousShownView = focusedView + } + } + + override fun onPageSelected(position: Int) { + val adapter = (viewPager.adapter as CustomViewAdapter) + if (adapter.previousShownView == null) { + // play if this page selection didn't trigger by a swipe from another page + play(adapter, position) + } else { + adapter.previousShownView?.stop() + adapter.previousShownView = null + adapter.newPosition = position + } + } + + // wait for the state to idle to prevent UI lag + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + val adapter = (viewPager.adapter as CustomViewAdapter) + val position = adapter.newPosition ?: return + adapter.newPosition = null + + play(adapter, position) + } + } + }) + } + + private fun updateZeroState() { + if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) { + zeroState.visibility = View.VISIBLE + } else { + zeroState.visibility = View.GONE + } + } + + private fun nextPage() { + nextPageTask?.cancel() + + val nextPageTask = + TaskHandler>(StateApp.instance.scopeGetter, { + currentShortsPager!!.nextPage() + + return@TaskHandler currentShortsPager!!.getResults() + }).success { newVideos -> + val prevCount = customViewAdapter!!.itemCount + currentShorts.addAll(newVideos) + if (isChannelShortsMode) { + channelShorts.addAll(newVideos) + } else { + mainShorts.addAll(newVideos) + } + customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + nextPageTask = null + } + + nextPageTask.run(this) + + this.nextPageTask = nextPageTask + } + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + private fun loadPager() { + loadPagerTask?.cancel() + + val loadPagerTask = + TaskHandler>(StateApp.instance.scopeGetter, { + val pager = StatePlatform.instance.getShorts() + + return@TaskHandler pager + }).success { pager -> + mainShorts.clear() + mainShorts.addAll(pager.getResults()) + mainShortsPager = pager + + if (!isChannelShortsMode) { + currentShorts.clear() + currentShorts.addAll(mainShorts) + currentShortsPager = pager + + // if the view pager exists go back to the beginning + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + loadPagerTask = null + }.exception { err -> + val message = "Unable to load shorts $err" + Logger.i(TAG, message) + if (context != null) { + UIDialogs.showDialog( + requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( + "Close", { }, UIDialogs.ActionStyle.PRIMARY + ) + ) + } + return@exception + } + + this.loadPagerTask = loadPagerTask + + loadPagerTask.run(this) + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = View.VISIBLE + } else { + overlayLoading.visibility = View.GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + override fun onPause() { + super.onPause() + customViewAdapter?.previousShownView?.pause() + } + + override fun onDestroy() { + super.onDestroy() + loadPagerTask?.cancel() + loadPagerTask = null + nextPageTask?.cancel() + nextPageTask = null + customViewAdapter?.previousShownView?.stop() + } + + companion object { + private const val TAG = "ShortsFragment" + + fun newInstance() = ShortsFragment() + } + + class CustomViewAdapter( + private val videos: MutableList, + private val inflater: LayoutInflater, + private val fragment: MainFragment, + private val overlayQualityContainer: FrameLayout, + private val isChannelShortsMode: () -> Boolean, + private val onNearEnd: () -> Unit, + ) : RecyclerView.Adapter() { + val onResetTriggered = Event0() + var previousShownView: ShortView? = null + var newPosition: Int? = null + var needToPlay: Int? = null + + @OptIn(UnstableApi::class) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + val shortView = ShortView(inflater, fragment, overlayQualityContainer) + shortView.onResetTriggered.subscribe { + onResetTriggered.emit() + } + return CustomViewHolder(shortView) + } + + @OptIn(UnstableApi::class) + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + holder.shortView.changeVideo(videos[position], isChannelShortsMode()) + + if (position == itemCount - 1) { + onNearEnd() + } + } + + override fun onViewRecycled(holder: CustomViewHolder) { + super.onViewRecycled(holder) + holder.shortView.cancel() + } + + override fun onViewAttachedToWindow(holder: CustomViewHolder) { + super.onViewAttachedToWindow(holder) + + if (holder.absoluteAdapterPosition == needToPlay) { + holder.shortView.play() + needToPlay = null + previousShownView = holder.shortView + } + } + + override fun getItemCount(): Int = videos.size + } + + @OptIn(UnstableApi::class) + class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) +} \ No newline at end of file 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 bd952cf2..47aa3959 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -463,6 +463,47 @@ class StatePlatform { pager.initialize(); return pager; } + fun getShorts(): IPager { + Logger.i(TAG, "Platform - getShorts"); + var clientIdsOngoing = mutableListOf(); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true }; + + StateApp.instance.scopeOrNull?.let { + it.launch(Dispatchers.Default) { + try { + // plugins that take longer than 5 seconds to load are considered "slow" + delay(5000); + val slowClients = synchronized(clientIdsOngoing) { + return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; + }; + for(client in slowClients) + UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast for slow source.", e) + } + } + } + + val pages = clients.parallelStream() + .map { + Logger.i(TAG, "getShorts - ${it.name}") + synchronized(clientIdsOngoing) { + clientIdsOngoing.add(it.id); + } + val shortsResult = it.fromPool(_pagerClientPool).getShorts(); + synchronized(clientIdsOngoing) { + clientIdsOngoing.remove(it.id); + } + return@map shortsResult; + } + .asSequence() + .toList() + .associateWith { 1f }; + + val pager = MultiDistributionContentPager(pages); + pager.initialize(); + return pager; + } suspend fun getHomeRefresh(scope: CoroutineScope): IPager { Logger.i(TAG, "Platform - getHome (Refresh)"); val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index 15c91025..eae8adf5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,6 +38,7 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; + private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -633,6 +634,13 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } + fun getShortPlayerOrCreate(context: Context) : PlayerManager { + if(_shortExoPlayer == null) { + val player = createExoPlayer(context); + _shortExoPlayer = PlayerManager(player); + } + return _shortExoPlayer!!; + } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -656,10 +664,13 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; + val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; + _shortExoPlayer = null player?.release(); thumbPlayer?.release(); + shortPlayer?.release() } diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index 22926841..90da8898 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,15 +34,18 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if(view != _currentView) { - _currentView?.player = null; - switchState(stateName); - view.player = player; - _currentView = view; + if (view != _currentView) { + _currentView?.player = null + _currentView = null + switchState(stateName) + view.player = player + _currentView = view } } + fun detach() { - _currentView?.player = null; + _currentView?.player = null + _currentView = null } fun getState(name: String): PlayerState { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index 3bd06903..c13a2df0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -9,8 +9,10 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment @@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec val onContentUrlClicked = Event2() val onUrlClicked = Event1() val onContentClicked = Event2() + val onShortClicked = Event3, ArrayList>?>() val onChannelClicked = Event1() val onAddToClicked = Event1() val onAddToQueueClicked = Event1() @@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec when (_tabs[position]) { ChannelTab.VIDEOS -> { fragment = ChannelContentsFragment.newInstance().apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe { video, num, _ -> + this@ChannelViewPagerAdapter.onContentClicked.emit(video, num) + } onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) @@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec ChannelTab.SHORTS -> { fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit) onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 27befb1e..0dba3c4f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout { inflate(context, R.layout.overlay_webview, this) _topbar = findViewById(R.id.topbar); _webview = findViewById(R.id.webview); - _webview.settings.javaScriptEnabled = true; + if (!isInEditMode){ + _webview.settings.javaScriptEnabled = true; + } _topbar.onClose.subscribe(this, onClose::emit); } diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index a1ccd142..7a3d5440 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -68,15 +68,7 @@ class CommentsList : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() }); }; - private val _scrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy); - onScrolled(); - - val totalScrollDistance = recyclerView.computeVerticalScrollOffset() - _layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE - } - }; + private val _scrollListener: RecyclerView.OnScrollListener private var _loader: (suspend () -> IPager)? = null; private val _adapterComments: InsertedViewAdapterWithLoader; @@ -131,6 +123,14 @@ class CommentsList : ConstraintLayout { _llmReplies = LinearLayoutManager(context); _recyclerComments.layoutManager = _llmReplies; _recyclerComments.adapter = _adapterComments; + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + onScrolled(); + + _layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE + } + }; _recyclerComments.addOnScrollListener(_scrollListener); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt new file mode 100644 index 00000000..cb5f1240 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.views.video + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.video.PlayerManager + +@UnstableApi +class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : + FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { + + companion object { + private const val TAG = "FutoShortVideoPlayer" + private const val PLAYER_STATE_NAME: String = "ShortPlayer" + } + + private var playerAttached = false + private val videoView: PlayerView + private val progressBar: DefaultTimeBar + private lateinit var player: PlayerManager + private var progressAnimator: ValueAnimator = createProgressBarAnimator() + + val onPlaybackStateChanged = Event1(); + + private var playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED + ) + ) { + progressAnimator.cancel() + if (player.duration >= 0) { + progressAnimator.duration = player.duration + setProgressBarDuration(player.duration) + progressAnimator.currentPlayTime = player.currentPosition + } + + if (player.isPlaying) { + progressAnimator.start() + } + } + + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + onPlaybackStateChanged.emit(player.playbackState) + } + } + } + + init { + LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) + videoView = findViewById(R.id.short_player_view) + progressBar = findViewById(R.id.short_player_progress_bar) + + if (!isInEditMode) { + player = StatePlayer.instance.getShortPlayerOrCreate(context) + player.player.repeatMode = Player.REPEAT_MODE_ONE + } + + progressBar.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + progressAnimator.cancel() + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) {} + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) { + progressAnimator.currentPlayTime = player.player.currentPosition + progressAnimator.duration = player.player.duration + progressAnimator.start() + return + } + + // the progress bar should never be available to the user without the player being attached to this view + assert(playerAttached) + seekTo(position) + } + }) + } + + @OptIn(UnstableApi::class) + private fun createProgressBarAnimator(): ValueAnimator { + return ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + progressBar.setPosition(animation.currentPlayTime) + } + } + } + + fun setProgressBarDuration(duration: Long) { + progressBar.setDuration(duration) + } + + /** + * Attaches this short player instance to the exo player instance for shorts + */ + fun attach() { + // connect the exo player for shorts to the view for this instance + player.attach(videoView, PLAYER_STATE_NAME) + + // direct the base player what exo player instance to use + changePlayer(player) + + playerAttached = true + + player.player.addListener(playerEventListener) + } + + fun detach() { + playerAttached = false + player.player.removeListener(playerEventListener) + player.detach() + } + + @OptIn(UnstableApi::class) + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + videoView.defaultArtwork = drawable + } else { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + videoView.defaultArtwork = null + } + } + + fun getPlaybackRate(): Float { + return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f + } + + fun setPlaybackRate(playbackRate: Float) { + val exoPlayer = exoPlayer?.player + Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}") + + val param = PlaybackParameters(playbackRate) + exoPlayer?.playbackParameters = param + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 023c79fc..0a4dd4e5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -6,6 +6,7 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -86,8 +87,6 @@ import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { private val TAG = "FutoVideoPlayerBase" - private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); - private var _mediaSource: MediaSource? = null; var lastVideoSource: IVideoSource? = null diff --git a/app/src/main/res/drawable/button_shadow.xml b/app/src/main/res/drawable/button_shadow.xml new file mode 100644 index 00000000..8b2e08a8 --- /dev/null +++ b/app/src/main/res/drawable/button_shadow.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/desktop_comments.xml b/app/src/main/res/drawable/desktop_comments.xml new file mode 100644 index 00000000..acdb15b4 --- /dev/null +++ b/app/src/main/res/drawable/desktop_comments.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_gear.xml b/app/src/main/res/drawable/desktop_gear.xml new file mode 100644 index 00000000..2001c903 --- /dev/null +++ b/app/src/main/res/drawable/desktop_gear.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_refresh.xml b/app/src/main/res/drawable/desktop_refresh.xml new file mode 100644 index 00000000..9625ff95 --- /dev/null +++ b/app/src/main/res/drawable/desktop_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_share.xml b/app/src/main/res/drawable/desktop_share.xml new file mode 100644 index 00000000..a98111ad --- /dev/null +++ b/app/src/main/res/drawable/desktop_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down.xml b/app/src/main/res/drawable/desktop_thumb_down.xml new file mode 100644 index 00000000..ca85aa53 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down_filled.xml b/app/src/main/res/drawable/desktop_thumb_down_filled.xml new file mode 100644 index 00000000..7939d8b8 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up.xml b/app/src/main/res/drawable/desktop_thumb_up.xml new file mode 100644 index 00000000..8a8eb280 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up_filled.xml b/app/src/main/res/drawable/desktop_thumb_up_filled.xml new file mode 100644 index 00000000..5e4a7790 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml new file mode 100644 index 00000000..f67f9d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_high_quality.xml b/app/src/main/res/drawable/ic_high_quality.xml new file mode 100644 index 00000000..4afa3e96 --- /dev/null +++ b/app/src/main/res/drawable/ic_high_quality.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml index 68758978..f9ae21c4 100644 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/> diff --git a/app/src/main/res/drawable/ic_smart_display_filled.xml b/app/src/main/res/drawable/ic_smart_display_filled.xml new file mode 100644 index 00000000..14245c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 8de5f492..3a80a3e3 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M262.65,192.31L666,192.31L666,628.92L415.69,880L403.6,871.19Q398.38,866.31 395.38,859.23Q392.38,852.15 392.38,843.77L392.38,839.92L433.54,628.92L136.85,628.92Q115.46,628.92 98.46,611.92Q81.46,594.92 81.46,573.54L81.46,523.18Q81.46,517.62 81.12,511.27Q80.77,504.92 83,499.46L195.15,237.15Q202.49,218.21 222.44,205.26Q242.39,192.31 262.65,192.31ZM635.23,223.08L256.69,223.08Q248.23,223.08 239.38,227.69Q230.54,232.31 225.92,243.08L112.23,512.08L112.23,573.54Q112.23,583.54 119.15,590.85Q126.08,598.15 136.85,598.15L470.62,598.15L424.54,829.46L635.23,615.46L635.23,223.08ZM635.23,615.46L635.23,615.46L635.23,598.15L635.23,598.15Q635.23,598.15 635.23,590.85Q635.23,583.54 635.23,573.54L635.23,512.08L635.23,243.08Q635.23,232.31 635.23,227.69Q635.23,223.08 635.23,223.08L635.23,223.08L635.23,615.46ZM666,628.92L666,598.15L809,598.15L809,223.08L666,223.08L666,192.31L839.77,192.31L839.77,628.92L666,628.92Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_down_filled.xml b/app/src/main/res/drawable/ic_thumb_down_filled.xml new file mode 100644 index 00000000..5517d2c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml index fdcf53d4..489f31d9 100644 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M696.77,800L293.54,800L293.54,363.38L543.08,112.31L555.84,121.12Q561.15,126 564.15,133.08Q567.15,140.15 567.15,147.77L567.15,152.38L525.23,363.38L822.69,363.38Q844.08,363.38 861.08,380.38Q878.08,397.38 878.08,418.77L878.08,469.13Q878.08,474.69 878.04,481.04Q878,487.38 875.77,492.85L764.38,755.15Q756,774.22 736.19,787.11Q716.38,800 696.77,800ZM324.31,769.23L702.85,769.23Q710.54,769.23 719.77,764.62Q729,760 733.62,749.23L847.31,480.23L847.31,418.77Q847.31,408.77 840,401.46Q832.69,394.15 822.69,394.15L488.92,394.15L534.23,162.85L324.31,376.85L324.31,769.23ZM324.31,376.85L324.31,376.85L324.31,394.15L324.31,394.15Q324.31,394.15 324.31,401.46Q324.31,408.77 324.31,418.77L324.31,480.23L324.31,749.23Q324.31,760 324.31,764.62Q324.31,769.23 324.31,769.23L324.31,769.23L324.31,376.85ZM293.54,363.38L293.54,394.15L150.54,394.15L150.54,769.23L293.54,769.23L293.54,800L119.77,800L119.77,363.38L293.54,363.38Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up_filled.xml b/app/src/main/res/drawable/ic_thumb_up_filled.xml new file mode 100644 index 00000000..03a4da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_down_selector.xml b/app/src/main/res/drawable/thumb_down_selector.xml new file mode 100644 index 00000000..4ae564a7 --- /dev/null +++ b/app/src/main/res/drawable/thumb_down_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_up_selector.xml b/app/src/main/res/drawable/thumb_up_selector.xml new file mode 100644 index 00000000..97d02623 --- /dev/null +++ b/app/src/main/res/drawable/thumb_up_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1708eeb4..df3abc69 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_shorts.xml b/app/src/main/res/layout/fragment_shorts.xml new file mode 100644 index 00000000..76c1a426 --- /dev/null +++ b/app/src/main/res/layout/fragment_shorts.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/modal_comments.xml b/app/src/main/res/layout/modal_comments.xml new file mode 100644 index 00000000..ca0e4c6c --- /dev/null +++ b/app/src/main/res/layout/modal_comments.xml @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +