From 1ccae849330957ac56def6a8d6de3fc6ee4f3c02 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Tue, 28 May 2024 17:05:35 -0500 Subject: [PATCH] add support for channel playlists on the channel page --- .../channel/tab/ChannelPlaylistsFragment.kt | 353 ++++++++++++++++++ .../mainactivity/main/ChannelFragment.kt | 20 +- .../views/adapters/ChannelViewPagerAdapter.kt | 14 +- 3 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt new file mode 100644 index 00000000..a344c7da --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt @@ -0,0 +1,353 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +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.platforms.js.models.JSPager +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.IRefreshPager +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.TaskHandler +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException +import com.futo.platformplayer.exceptions.ChannelException +import com.futo.platformplayer.fragment.mainactivity.main.FeedView +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateCache +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { + private var _recyclerResults: RecyclerView? = null + private var _llmVideo: LinearLayoutManager? = null + private var _loading = false + private var _pagerParent: IPager? = null + private var _pager: IPager? = null + private var _cache: FeedView.ItemCache? = null + private var _channel: IPlatformChannel? = null + private var _results: ArrayList = arrayListOf() + private var _adapterResults: InsertedViewAdapterWithLoader? = null + private var _lastPolycentricProfile: PolycentricProfile? = null + + val onContentClicked = Event2() + val onContentUrlClicked = Event2() + val onUrlClicked = Event1() + val onChannelClicked = Event1() + val onAddToClicked = Event1() + val onAddToQueueClicked = Event1() + val onAddToWatchLaterClicked = Event1() + val onLongPress = Event1() + + private fun getPlaylistPager(channel: IPlatformChannel): IPager { + Logger.i(TAG, "getPlaylistPager") + + return StatePlatform.instance.getChannelPlaylists(channel.url) as IPager + } + + private val _taskLoadVideos = + TaskHandler>({ lifecycleScope }, { + val livePager = getPlaylistPager(it) + return@TaskHandler if (_channel?.let { channel -> + StateSubscriptions.instance.isSubscribed( + channel + ) + } == true) + StateCache.cachePagerResults(lifecycleScope, livePager) + else livePager + }).success { livePager -> + setLoading(false) + + setPager(livePager) + } + .exception { } + .exception { + Logger.w(TAG, "Failed to load initial videos.", it) + UIDialogs.showGeneralRetryErrorDialog( + requireContext(), + it.message ?: "", + it, + { loadNextPage() }) + } + + private var _nextPageHandler: TaskHandler, List> = + TaskHandler, List>({ lifecycleScope }, { + if (it is IAsyncPager<*>) + it.nextPageAsync() + else + it.nextPage() + + processPagerExceptions(it) + return@TaskHandler it.getResults() + }).success { + setLoading(false) + val posBefore = _results.size + //val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo } + _results.addAll(it) + _adapterResults?.let { adapterVideo -> + adapterVideo.notifyItemRangeInserted( + adapterVideo.childToParentPosition( + posBefore + ), it.size + ) + } + }.exception { + Logger.w(TAG, "Failed to load next page.", it) + UIDialogs.showGeneralRetryErrorDialog( + requireContext(), + it.message ?: "", + it, + { loadNextPage() }) + } + + private val _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val recyclerResults = _recyclerResults ?: return + val llmVideo = _llmVideo ?: return + + val visibleItemCount = recyclerResults.childCount + val firstVisibleItem = llmVideo.findFirstVisibleItemPosition() + val visibleThreshold = 15 + if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size) { + loadNextPage() + } + } + } + + override fun setChannel(channel: IPlatformChannel) { + val c = _channel + if (c != null && c.url == channel.url) { + Logger.i(TAG, "setChannel skipped because previous was same") + return + } + + Logger.i(TAG, "setChannel setChannel=${channel}") + + _taskLoadVideos.cancel() + + _channel = channel + _results.clear() + _adapterResults?.notifyDataSetChanged() + + loadInitial() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_channel_videos, container, false) + + _recyclerResults = view.findViewById(R.id.recycler_videos) + + _adapterResults = PreviewContentListAdapter( + view.context, + FeedStyle.THUMBNAIL, + _results, + null, + Settings.instance.channel.progressBar + ).apply { + this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit) + this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit) + this.onContentClicked.subscribe(this@ChannelPlaylistsFragment.onContentClicked::emit) + this.onChannelClicked.subscribe(this@ChannelPlaylistsFragment.onChannelClicked::emit) + this.onAddToClicked.subscribe(this@ChannelPlaylistsFragment.onAddToClicked::emit) + this.onAddToQueueClicked.subscribe(this@ChannelPlaylistsFragment.onAddToQueueClicked::emit) + this.onAddToWatchLaterClicked.subscribe(this@ChannelPlaylistsFragment.onAddToWatchLaterClicked::emit) + this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit) + } + + _llmVideo = LinearLayoutManager(view.context) + _recyclerResults?.adapter = _adapterResults + _recyclerResults?.layoutManager = _llmVideo + _recyclerResults?.addOnScrollListener(_scrollListener) + + return view + } + + override fun onDestroyView() { + super.onDestroyView() + _recyclerResults?.removeOnScrollListener(_scrollListener) + _recyclerResults = null + _pager = null + + _taskLoadVideos.cancel() + _nextPageHandler.cancel() + } + + private fun setPager( + pager: IPager, + cache: FeedView.ItemCache? = null + ) { + if (_pagerParent != null && _pagerParent is IRefreshPager<*>) { + (_pagerParent as IRefreshPager<*>).onPagerError.remove(this) + (_pagerParent as IRefreshPager<*>).onPagerChanged.remove(this) + _pagerParent = null + } + if (_pager is IReplacerPager<*>) + (_pager as IReplacerPager<*>).onReplaced.remove(this) + + val pagerToSet: IPager? + if (pager is IRefreshPager<*>) { + _pagerParent = pager + pagerToSet = pager.getCurrentPager() as IPager + pager.onPagerChanged.subscribe(this) { + + lifecycleScope.launch(Dispatchers.Main) { + try { + loadPagerInternal(it as IPager) + } catch (e: Throwable) { + Logger.e(TAG, "loadPagerInternal failed.", e) + } + } + } + pager.onPagerError.subscribe(this) { + Logger.e(TAG, "Search pager failed: ${it.message}", it) + if (it is PluginException) + UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}") + else + UIDialogs.toast("Plugin failed due to:\n${it.message}") + } + } else pagerToSet = pager + + loadPagerInternal(pagerToSet, cache) + } + + private fun loadPagerInternal( + pager: IPager, + cache: FeedView.ItemCache? = null + ) { + _cache = cache + + if (_pager is IReplacerPager<*>) + (_pager as IReplacerPager<*>).onReplaced.remove(this) + if (pager is IReplacerPager<*>) { + pager.onReplaced.subscribe(this) { oldItem, newItem -> + if (_pager != pager) + return@subscribe + + lifecycleScope.launch(Dispatchers.Main) { + val toReplaceIndex = _results.indexOfFirst { it == oldItem } + if (toReplaceIndex >= 0) { + _results[toReplaceIndex] = newItem as IPlatformContent + _adapterResults?.let { + it.notifyItemChanged(it.childToParentPosition(toReplaceIndex)) + } + } + } + } + } + + _pager = pager + + processPagerExceptions(pager) + + _results.clear() + val toAdd = pager.getResults() + _results.addAll(toAdd) + _adapterResults?.notifyDataSetChanged() + _recyclerResults?.scrollToPosition(0) + } + + private fun loadInitial() { + val channel: IPlatformChannel = _channel ?: return + setLoading(true) + _taskLoadVideos.run(channel) + } + + private fun loadNextPage() { + val pager: IPager = _pager ?: return + if (_pager?.hasMorePages() == true) { + setLoading(true) + _nextPageHandler.run(pager) + } + } + + private fun setLoading(loading: Boolean) { + _loading = loading + _adapterResults?.setLoading(loading) + } + + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + val p = _lastPolycentricProfile + if (p != null && polycentricProfile != null && p.system == polycentricProfile.system) { + Logger.i( + ChannelContentsFragment.TAG, + "setPolycentricProfile skipped because previous was same" + ) + return + } + + _lastPolycentricProfile = polycentricProfile + + if (polycentricProfile != null) { + _taskLoadVideos.cancel() + val itemsRemoved = _results.size + _results.clear() + _adapterResults?.notifyItemRangeRemoved(0, itemsRemoved) + loadInitial() + } + } + + private fun processPagerExceptions(pager: IPager<*>) { + if (pager is MultiPager<*> && pager.allowFailure) { + val ex = pager.getResultExceptions() + for (kv in ex) { + val jsVideoPager: JSPager<*>? = when (kv.key) { + is MultiPager<*> -> (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>? + is JSPager<*> -> kv.key as JSPager<*> + else -> null + } + + context?.let { + lifecycleScope.launch(Dispatchers.Main) { + try { + val channel = + if (kv.value is ChannelException) (kv.value as ChannelException).channelNameOrUrl else null + if (jsVideoPager != null) + UIDialogs.toast( + it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n" + + (if (!channel.isNullOrEmpty()) "(${channel}) " else "") + + "${kv.value.message}", false + ) + else + UIDialogs.toast(it, kv.value.message ?: "", false) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e) + } + } + } + } + } + } + + companion object { + const val TAG = "PlaylistsFragment" + fun newInstance() = ChannelPlaylistsFragment().apply { } + } +} \ No newline at end of file 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 3359119f..2edb7e44 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 @@ -32,6 +32,7 @@ import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger @@ -57,6 +58,8 @@ import kotlinx.serialization.Serializable @Serializable data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List); +const val PLAYLIST_POSITION = 4 + class ChannelFragment : MainFragment() { override val isMainView : Boolean = true; override val hasBottomBar: Boolean = true; @@ -241,6 +244,7 @@ class ChannelFragment : MainFragment() { //2 -> "STORE" 2 -> "SUPPORT" 3 -> "ABOUT" + PLAYLIST_POSITION -> "PLAYLISTS" else -> "Unknown $position" }; }; @@ -384,6 +388,10 @@ class ChannelFragment : MainFragment() { private fun showChannel(channel: IPlatformChannel) { setLoading(false); + if (!StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists) { + _tabs.removeTabAt(PLAYLIST_POSITION) + } + _fragment.topBar?.onShown(channel); val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { @@ -435,6 +443,10 @@ class ChannelFragment : MainFragment() { it.getFragment().setChannel(channel); it.getFragment().setChannel(channel); it.getFragment().setChannel(channel); + if (StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists) { + Logger.w(TAG, "Supported channel playlists??"); + it.getFragment().setChannel(channel); + } //TODO: Call on other tabs as needed } @@ -501,6 +513,12 @@ class ChannelFragment : MainFragment() { it.getFragment().setPolycentricProfile(profile); it.getFragment().setPolycentricProfile(profile); it.getFragment().setPolycentricProfile(profile); + channel?.let { channel -> + if (StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists) { + Logger.w(TAG, "Supported channel playlists??"); + it.getFragment().setPolycentricProfile(profile); + } + } //TODO: Call on other tabs as needed } } @@ -510,4 +528,4 @@ class ChannelFragment : MainFragment() { val TAG = "ChannelFragment"; fun newInstance() = ChannelFragment().apply { } } -} \ No newline at end of file +} 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 de0f6c97..e908084b 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 @@ -12,7 +12,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.fragment.channel.tab.* class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) { - private val _cache: Array = arrayOfNulls(4); + private val _cache: Array = arrayOfNulls(5); val onContentUrlClicked = Event2(); val onUrlClicked = Event1(); @@ -39,6 +39,8 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec return createFragment(2) as T; else if(T::class == ChannelAboutFragment::class) return createFragment(3) as T; + else if(T::class == ChannelPlaylistsFragment::class) + return createFragment(4) as T; else throw NotImplementedError("Implement other types"); } @@ -64,6 +66,16 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec //2 -> ChannelStoreFragment.newInstance(); 2 -> ChannelMonetizationFragment.newInstance(); 3 -> ChannelAboutFragment.newInstance(); + 4 -> ChannelPlaylistsFragment.newInstance().apply { + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit); + onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit); + onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit); + onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit); + onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit); + onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit); + onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit); + onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit); + }; else -> throw IllegalStateException("Invalid tab position $position") };