From 1ccae849330957ac56def6a8d6de3fc6ee4f3c02 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Tue, 28 May 2024 17:05:35 -0500 Subject: [PATCH 01/28] 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") }; From 035d19f581daaa3c773ab8e6e1bc5ad970605f4d Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 3 Jun 2024 16:30:05 +0000 Subject: [PATCH 02/28] Update LICENSE --- LICENSE | 61 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/LICENSE b/LICENSE index a3d0f019..f3d7e742 100644 --- a/LICENSE +++ b/LICENSE @@ -1,32 +1,43 @@ -# FUTO TEMPORARY LICENSE -This license grants you the rights, and only the rights, set out below in respect of the source code provided. If you take advantage of these rights, you accept this license. If you do not accept the license, do not access the code. +# Grayjay Core License 1.0 -Words used in the Terms of Service have the same meaning in this license. Where there is any inconsistency between this license and those Terms of Service, these terms prevail. +## Acceptance +By using the software, you agree to all of the terms and conditions below. -## Section 1: Definitions -- "code" means the source code made available from time, in our sole discretion, for access under this license. Reference to code in this license means the code and any part of it and any derivative of it. -- “compilation” means to compile the code from ‘source code’ to ‘machine code’. -- "defect" means a defect, bug, backdoor, security issue or other deficiency in the code. -- “non-commercial distribution” means distribution of the code or any compilation of the code, or of any other application or program containing the code or any compilation of the code, where such distribution is not intended for or directed towards commercial advantage or monetary compensation. -- "review" means to access, analyse, test and otherwise review the code as a reference, for the sole purpose of analysing it for defects. -- "you" means the licensee of rights set out in this license. +## Copyright License +FUTO Holdings, Inc. (the “Licensor”) grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations below. -## Section 2: Grant of Rights -1. Subject to the terms of this license, we grant you a non-transferable, non-exclusive, worldwide, royalty-free license to access and use the code solely for the purposes of review, compilation and non-commercial distribution. -2. You may provide the code to anyone else and publish excerpts of it for the purposes of review, compilation and non-commercial distribution, provided that when you do so you make any recipient of the code aware of the terms of this license, they must agree to be bound by the terms of this license and you must attribute the code to the provider. -3. Other than in respect of those parts of the code that were developed by other parties and as specified strictly in accordance with the open source and other licenses under which those parts of the code have been made available, as set out on our website or in those items of code, you are not entitled to use or do anything with the code for any commercial or other purpose, other than review, compilation and non-commercial distribution in accordance with the terms of this license. -4. Subject to the terms of this license, you must at all times comply with and shall be bound by our Terms of Use, Privacy and Data Policy. +## Limitations +You may use or modify the software for only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application. -## Section 3: Limitations -1. This license does not grant you any rights to use the provider's name, logo, or trademarks and you must not in any way indicate you are authorised to speak on behalf of the provider. -2. If you issue proceedings in any jurisdiction against the provider because you consider the provider has infringed copyright or any patent right in respect of the code (including any joinder or counterclaim), your license to the code is automatically terminated. -3. THE CODE IS MADE AVAILABLE "AS-IS" AND WITHOUT ANY EXPRESS OR IMPLIED GUARANTEES AS TO FITNESS, MERCHANTABILITY, NON-INFRINGEMENT OR OTHERWISE. IT IS NOT BEING PROVIDED IN TRADE BUT ON A VOLUNTARY BASIS ON OUR PART AND IS NOT MADE AVAILABLE FOR ANY USE OUTSIDE THE TERMS OF THIS LICENSE. ANYONE ACCESSING THE CODE MUST ENSURE THEY HAVE THE REQUISITE EXPERTISE TO SECURE THEIR OWN SYSTEM AND DEVICES AND TO ACCESS AND USE THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE. YOU BEAR THE RISK OF ACCESSING AND USING THE CODE. IN PARTICULAR, THE PROVIDER BEARS NO LIABILITY FOR ANY INTERFERENCE WITH OR ADVERSE EFFECT ON YOUR SYSTEM OR DEVICES AS A RESULT OF YOUR ACCESSING AND USING THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE OR OTHERWISE. +You may distribute the software or provide it to others only if you do so free of charge for non-commercial purposes. -## Section 4: Termination, suspension and variation -1. We may suspend, terminate or vary the terms of this license and any access to the code at any time, without notice, for any reason or no reason, in respect of any licensee, group of licensees or all licensees including as may be applicable any sub-licensees. +Notwithstanding the above, you may not remove or obscure any functionality in the software related to payment to the Licensor in any copy you distribute to others. -## Section 5: General -1. This license and its interpretation and operation are governed solely by the local law. You agree to submit to the exclusive jurisdiction of the local arbitral tribunals as further described in our Terms of Service and you agree not to raise any jurisdictional issue if we need to enforce an arbitral award or judgment in our jurisdiction or another country. -2. Questions and comments regarding this license are welcomed and should be addressed at https://chat.futo.org/login/. +You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law. -Last updated 7 June 2023. +## Patents +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Notices +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section. + +## Fair Use +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the Licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Termination +If you use the software in violation of these terms, such use is not licensed, and your license will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your license will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your license to terminate automatically and permanently. + +## No Liability +As far as the law allows, the software comes as is, without any warranty or condition, and the Licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim. + +## Definitions +- The “Licensor” is the entity offering these terms, FUTO Holdings, Inc. +- The “software” is the software the licensor makes available under these terms, including any portion of it. +- “You” refers to the individual or entity agreeing to these terms. +- “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. Control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. +- “Your license” is the license granted to you for the software under these terms. +- “Use” means anything you do with the software requiring your license. +- “Trademark” means trademarks, service marks, and similar rights. From 309332ac9cacc6ed0abcc9f5bf95d58db65da630 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 3 Jun 2024 16:30:27 +0000 Subject: [PATCH 03/28] Update LICENSE --- LICENSE => LICENSE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename LICENSE => LICENSE.md (100%) diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md From 5edd389e841a9b12b42694702cdd898825a5f0ac Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Tue, 4 Jun 2024 20:22:42 -0500 Subject: [PATCH 04/28] removed hardcoding. fixed bugs. hide CHANNELS and SUPPORT for non polycentric linked channels --- .../channel/tab/ChannelAboutFragment.kt | 2 +- .../channel/tab/ChannelContentsFragment.kt | 2 +- .../channel/tab/ChannelListFragment.kt | 2 +- .../tab/ChannelMonetizationFragment.kt | 2 +- .../channel/tab/ChannelPlaylistsFragment.kt | 154 ++-- .../channel/tab/IChannelTabFragment.kt | 8 +- .../mainactivity/main/ChannelFragment.kt | 658 ++++++++++-------- .../views/adapters/ChannelViewPagerAdapter.kt | 158 +++-- 8 files changed, 505 insertions(+), 481 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt index f7d9d9bf..19f1454b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt @@ -114,7 +114,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment { } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { _lastPolycentricProfile = polycentricProfile; if (polycentricProfile == null) { 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 0647afed..ea8ee4fa 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 @@ -309,7 +309,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { _adapterResults?.setLoading(loading); } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { val p = _lastPolycentricProfile; if (p != null && polycentricProfile != null && p.system == polycentricProfile.system) { Logger.i(TAG, "setPolycentricProfile skipped because previous was same"); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt index 00ec5982..807fbd90 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt @@ -124,7 +124,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment { } } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { _taskLoadChannel.cancel(); _lastPolycentricProfile = polycentricProfile; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt index e2e39ee7..53268d16 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt @@ -46,7 +46,7 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment { _lastChannel = channel; } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { _lastPolycentricProfile = polycentricProfile if (polycentricProfile != null) { _supportView?.setPolycentricProfile(polycentricProfile) 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 index a344c7da..dbb58b68 100644 --- 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 @@ -15,6 +15,7 @@ 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.models.playlists.IPlatformPlaylist 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 @@ -27,12 +28,8 @@ 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 @@ -42,15 +39,13 @@ import kotlinx.coroutines.launch class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { private var _recyclerResults: RecyclerView? = null - private var _llmVideo: LinearLayoutManager? = null + private var _llmPlaylist: LinearLayoutManager? = null private var _loading = false - private var _pagerParent: IPager? = null - private var _pager: IPager? = null - private var _cache: FeedView.ItemCache? = null + private var _pagerParent: IPager? = null + private var _pager: IPager? = 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() @@ -61,62 +56,49 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { val onAddToWatchLaterClicked = Event1() val onLongPress = Event1() - private fun getPlaylistPager(channel: IPlatformChannel): IPager { + private fun getPlaylistPager(channel: IPlatformChannel): IPager { Logger.i(TAG, "getPlaylistPager") - return StatePlatform.instance.getChannelPlaylists(channel.url) as IPager + return StatePlatform.instance.getChannelPlaylists(channel.url) } - private val _taskLoadVideos = - TaskHandler>({ lifecycleScope }, { + private val _taskLoadPlaylists = + TaskHandler>({ lifecycleScope }, { val livePager = getPlaylistPager(it) - return@TaskHandler if (_channel?.let { channel -> - StateSubscriptions.instance.isSubscribed( - channel - ) - } == true) - StateCache.cachePagerResults(lifecycleScope, livePager) - else livePager + return@TaskHandler livePager }).success { livePager -> setLoading(false) setPager(livePager) - } - .exception { } - .exception { - Logger.w(TAG, "Failed to load initial videos.", it) - UIDialogs.showGeneralRetryErrorDialog( - requireContext(), + }.exception { }.exception { + Logger.w(TAG, "Failed to load initial playlists.", 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() + 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( + _adapterResults?.let { adapterResult -> + adapterResult.notifyItemRangeInserted( + adapterResult.childToParentPosition( posBefore ), it.size ) } }.exception { Logger.w(TAG, "Failed to load next page.", it) - UIDialogs.showGeneralRetryErrorDialog( - requireContext(), + UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }) @@ -127,10 +109,10 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { super.onScrolled(recyclerView, dx, dy) val recyclerResults = _recyclerResults ?: return - val llmVideo = _llmVideo ?: return + val llmPlaylist = _llmPlaylist ?: return val visibleItemCount = recyclerResults.childCount - val firstVisibleItem = llmVideo.findFirstVisibleItemPosition() + val firstVisibleItem = llmPlaylist.findFirstVisibleItemPosition() val visibleThreshold = 15 if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size) { loadNextPage() @@ -147,7 +129,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { Logger.i(TAG, "setChannel setChannel=${channel}") - _taskLoadVideos.cancel() + _taskLoadPlaylists.cancel() _channel = channel _results.clear() @@ -157,20 +139,14 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { } override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? + 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 + 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) @@ -182,9 +158,9 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit) } - _llmVideo = LinearLayoutManager(view.context) + _llmPlaylist = LinearLayoutManager(view.context) _recyclerResults?.adapter = _adapterResults - _recyclerResults?.layoutManager = _llmVideo + _recyclerResults?.layoutManager = _llmPlaylist _recyclerResults?.addOnScrollListener(_scrollListener) return view @@ -196,31 +172,29 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { _recyclerResults = null _pager = null - _taskLoadVideos.cancel() + _taskLoadPlaylists.cancel() _nextPageHandler.cancel() } private fun setPager( - pager: IPager, - cache: FeedView.ItemCache? = null + pager: IPager ) { 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) + if (_pager is IReplacerPager<*>) (_pager as IReplacerPager<*>).onReplaced.remove(this) - val pagerToSet: IPager? + val pagerToSet: IPager? if (pager is IRefreshPager<*>) { _pagerParent = pager - pagerToSet = pager.getCurrentPager() as IPager + pagerToSet = pager.getCurrentPager() as IPager pager.onPagerChanged.subscribe(this) { lifecycleScope.launch(Dispatchers.Main) { try { - loadPagerInternal(it as IPager) + loadPagerInternal(it as IPager) } catch (e: Throwable) { Logger.e(TAG, "loadPagerInternal failed.", e) } @@ -228,33 +202,26 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { } 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}") + 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) + loadPagerInternal(pagerToSet) } private fun loadPagerInternal( - pager: IPager, - cache: FeedView.ItemCache? = null + pager: IPager ) { - _cache = cache - - if (_pager is IReplacerPager<*>) - (_pager as IReplacerPager<*>).onReplaced.remove(this) + 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 + if (_pager != pager) return@subscribe lifecycleScope.launch(Dispatchers.Main) { val toReplaceIndex = _results.indexOfFirst { it == oldItem } if (toReplaceIndex >= 0) { - _results[toReplaceIndex] = newItem as IPlatformContent + _results[toReplaceIndex] = newItem as IPlatformPlaylist _adapterResults?.let { it.notifyItemChanged(it.childToParentPosition(toReplaceIndex)) } @@ -277,11 +244,11 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { private fun loadInitial() { val channel: IPlatformChannel = _channel ?: return setLoading(true) - _taskLoadVideos.run(channel) + _taskLoadPlaylists.run(channel) } private fun loadNextPage() { - val pager: IPager = _pager ?: return + val pager: IPager = _pager ?: return if (_pager?.hasMorePages() == true) { setLoading(true) _nextPageHandler.run(pager) @@ -293,32 +260,11 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { _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) { + val jsPager: JSPager<*>? = when (kv.key) { is MultiPager<*> -> (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>? is JSPager<*> -> kv.key as JSPager<*> else -> null @@ -329,14 +275,12 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { 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) + if (jsPager != null) UIDialogs.toast( + it, + "Plugin ${jsPager.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) } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt index e1da77c5..2b615d25 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt @@ -1,7 +1,11 @@ package com.futo.platformplayer.fragment.channel.tab import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile interface IChannelTabFragment { - fun setChannel(channel: IPlatformChannel); -} \ No newline at end of file + fun setChannel(channel: IPlatformChannel) + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + + } +} 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 2edb7e44..f6126c58 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 @@ -15,8 +15,9 @@ import androidx.appcompat.widget.AppCompatImageView import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide -import com.futo.platformplayer.* import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.models.PlatformAuthorLink @@ -27,27 +28,32 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.assume import com.futo.platformplayer.constructs.TaskHandler -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.dp +import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.ChannelTab import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.subscriptions.SubscribeButton -import com.futo.polycentric.core.* +import com.futo.polycentric.core.OwnedClaim +import com.futo.polycentric.core.PublicKey +import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers @@ -56,476 +62,510 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @Serializable -data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List); - -const val PLAYLIST_POSITION = 4 +data class PolycentricProfile( + val system: PublicKey, val systemState: SystemState, val ownedClaims: List +) class ChannelFragment : MainFragment() { - override val isMainView : Boolean = true; - override val hasBottomBar: Boolean = true; - private var _view: ChannelView? = null; + override val isMainView: Boolean = true + override val hasBottomBar: Boolean = true + private var _view: ChannelView? = null override fun onShownWithView(parameter: Any?, isBack: Boolean) { - super.onShownWithView(parameter, isBack); - _view?.onShown(parameter, isBack); + super.onShownWithView(parameter, isBack) + _view?.onShown(parameter, isBack) } - override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = ChannelView(this, inflater); - _view = view; - return view; + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val view = ChannelView(this, inflater) + _view = view + return view } override fun onBackPressed(): Boolean { - return _view?.onBackPressed() ?: false; + return _view?.onBackPressed() ?: false } override fun onDestroyMainView() { - super.onDestroyMainView(); + super.onDestroyMainView() - _view?.cleanup(); - _view = null; + _view?.cleanup() + _view = null } fun selectTab(selectedTabIndex: Int) { - _view?.selectTab(selectedTabIndex); + _view?.selectTab(selectedTabIndex) } @SuppressLint("ViewConstructor") - class ChannelView : LinearLayout { - private val _fragment: ChannelFragment; + class ChannelView + (fragment: ChannelFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) { + private val _fragment: ChannelFragment = fragment - private var _textChannel: TextView; - private var _textChannelSub: TextView; - private var _creatorThumbnail: CreatorThumbnail; - private var _imageBanner: AppCompatImageView; + private var _textChannel: TextView + private var _textChannelSub: TextView + private var _creatorThumbnail: CreatorThumbnail + private var _imageBanner: AppCompatImageView - private var _tabs: TabLayout; - private var _viewPager: ViewPager2; - private var _tabLayoutMediator: TabLayoutMediator; - private var _buttonSubscribe: SubscribeButton; - private var _buttonSubscriptionSettings: ImageButton; + private var _tabs: TabLayout + private var _viewPager: ViewPager2 - private var _overlayContainer: FrameLayout; - private var _overlay_loading: LinearLayout; - private var _overlay_loading_spinner: ImageView; + // private var _adapter: ChannelViewPagerAdapter; + private var _tabLayoutMediator: TabLayoutMediator + private var _buttonSubscribe: SubscribeButton + private var _buttonSubscriptionSettings: ImageButton - private var _slideUpOverlay: SlideUpMenuOverlay? = null; + private var _overlayContainer: FrameLayout + private var _overlayLoading: LinearLayout + private var _overlayLoadingSpinner: ImageView - private var _isLoading: Boolean = false; - private var _selectedTabIndex: Int = -1; + private var _slideUpOverlay: SlideUpMenuOverlay? = null + + private var _isLoading: Boolean = false + private var _selectedTabIndex: Int = -1 var channel: IPlatformChannel? = null - private set; - private var _url: String? = null; + private set + private var _url: String? = null - private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - super.onPageScrolled(position, positionOffset, positionOffsetPixels); - //recalculate(position, positionOffset); - } - } + private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {} - private val _taskLoadPolycentricProfile: TaskHandler; - private val _taskGetChannel: TaskHandler; + private val _taskLoadPolycentricProfile: TaskHandler + private val _taskGetChannel: TaskHandler - constructor(fragment: ChannelFragment, inflater: LayoutInflater) : super(inflater.context) { - _fragment = fragment; - inflater.inflate(R.layout.fragment_channel, this); - - _taskLoadPolycentricProfile = TaskHandler({fragment.lifecycleScope}, { id -> - return@TaskHandler PolycentricCache.instance.getProfileAsync(id); - }) - .success { it -> setPolycentricProfile(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load polycentric profile.", it); - }; - - _taskGetChannel = TaskHandler({fragment.lifecycleScope}, { url -> StatePlatform.instance.getChannelLive(url) }) - .success { showChannel(it); } + init { + inflater.inflate(R.layout.fragment_channel, this) + _taskLoadPolycentricProfile = + TaskHandler({ fragment.lifecycleScope }, + { id -> + return@TaskHandler PolycentricCache.instance.getProfileAsync(id) + }).success { setPolycentricProfile(it, animate = true) }.exception { + Logger.w(TAG, "Failed to load polycentric profile.", it) + } + _taskGetChannel = TaskHandler({ fragment.lifecycleScope }, + { url -> StatePlatform.instance.getChannelLive(url) }).success { showChannel(it); } .exception { - UIDialogs.showDialog(context, + UIDialogs.showDialog( + context, R.drawable.ic_sources, - context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", null, null, + context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", + null, + null, 0, UIDialogs.Action("Back", { - fragment.close(true); + fragment.close(true) }, UIDialogs.ActionStyle.PRIMARY) - ); + ) + }.exception { + Logger.e(TAG, "Failed to load channel.", it) + UIDialogs.showGeneralRetryErrorDialog( + context, it.message ?: "", it, { loadChannel() }, null, fragment + ) } - .exception { - Logger.e(TAG, "Failed to load channel.", it); - UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() }, null, fragment); - } - - val tabs: TabLayout = findViewById(R.id.tabs); - val viewPager: ViewPager2 = findViewById(R.id.view_pager); - _textChannel = findViewById(R.id.text_channel_name); - _textChannelSub = findViewById(R.id.text_metadata); - _creatorThumbnail = findViewById(R.id.creator_thumbnail); - _imageBanner = findViewById(R.id.image_channel_banner); - _buttonSubscribe = findViewById(R.id.button_subscribe); - _buttonSubscriptionSettings = findViewById(R.id.button_sub_settings); - _overlay_loading = findViewById(R.id.channel_loading_overlay); - _overlay_loading_spinner = findViewById(R.id.channel_loader); - _overlayContainer = findViewById(R.id.overlay_container); - + val tabs: TabLayout = findViewById(R.id.tabs) + val viewPager: ViewPager2 = findViewById(R.id.view_pager) + _textChannel = findViewById(R.id.text_channel_name) + _textChannelSub = findViewById(R.id.text_metadata) + _creatorThumbnail = findViewById(R.id.creator_thumbnail) + _imageBanner = findViewById(R.id.image_channel_banner) + _buttonSubscribe = findViewById(R.id.button_subscribe) + _buttonSubscriptionSettings = findViewById(R.id.button_sub_settings) + _overlayLoading = findViewById(R.id.channel_loading_overlay) + _overlayLoadingSpinner = findViewById(R.id.channel_loader) + _overlayContainer = findViewById(R.id.overlay_container) _buttonSubscribe.onSubscribed.subscribe { - UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); - _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; + UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer) + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE } _buttonSubscribe.onUnSubscribed.subscribe { - _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE } - _buttonSubscriptionSettings.setOnClickListener { - val url = channel?.url ?: _url ?: return@setOnClickListener; - val sub = StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener; - UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer); - }; + val url = channel?.url ?: _url ?: return@setOnClickListener + val sub = + StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener + UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer) + } //TODO: Determine if this is really the only solution (isSaveEnabled=false) - viewPager.isSaveEnabled = false; - viewPager.registerOnPageChangeCallback(_onPageChangeCallback); - val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle); + viewPager.isSaveEnabled = false + viewPager.registerOnPageChangeCallback(_onPageChangeCallback) + val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle) adapter.onChannelClicked.subscribe { c -> fragment.navigate(c) } adapter.onContentClicked.subscribe { v, _ -> - if(v is IPlatformVideo) { - StatePlayer.instance.clearQueue(); - fragment.navigate(v).maximizeVideoDetail(); - } else if (v is IPlatformPlaylist) { - fragment.navigate(v); - } else if (v is IPlatformPost) { - fragment.navigate(v); + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(v).maximizeVideoDetail() + } + + is IPlatformPlaylist -> { + fragment.navigate(v) + } + + is IPlatformPost -> { + fragment.navigate(v) + } } } - adapter.onAddToClicked.subscribe {content -> + adapter.onAddToClicked.subscribe { content -> _overlayContainer.let { - if(content is IPlatformVideo) - _slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it); + if (content is IPlatformVideo) _slideUpOverlay = + UISlideOverlays.showVideoOptionsOverlay(content, it) } } adapter.onAddToQueueClicked.subscribe { content -> - if(content is IPlatformVideo) { - StatePlayer.instance.addToQueue(content); + if (content is IPlatformVideo) { + StatePlayer.instance.addToQueue(content) } } adapter.onAddToWatchLaterClicked.subscribe { content -> - if(content is IPlatformVideo) { - StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content)); - UIDialogs.toast("Added to watch later\n[${content.name}]"); + if (content is IPlatformVideo) { + StatePlaylists.instance.addToWatchLater( + SerializedPlatformVideo.fromVideo( + content + ) + ) + UIDialogs.toast("Added to watch later\n[${content.name}]") } } adapter.onUrlClicked.subscribe { url -> - fragment.navigate(url); + fragment.navigate(url) } adapter.onContentUrlClicked.subscribe { url, contentType -> - when(contentType) { + when (contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue(); - fragment.navigate(url).maximizeVideoDetail(); - }; - ContentType.URL -> fragment.navigate(url); - else -> {}; + StatePlayer.instance.clearQueue() + fragment.navigate(url).maximizeVideoDetail() + } + + ContentType.URL -> fragment.navigate(url) + else -> {} } } adapter.onLongPress.subscribe { content -> _overlayContainer.let { - if(content is IPlatformVideo) - _slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it); + if (content is IPlatformVideo) _slideUpOverlay = + UISlideOverlays.showVideoOptionsOverlay(content, it) } } - viewPager.adapter = adapter; - - val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position -> - tab.text = when (position) { - 0 -> "VIDEOS" - 1 -> "CHANNELS" - //2 -> "STORE" - 2 -> "SUPPORT" - 3 -> "ABOUT" - PLAYLIST_POSITION -> "PLAYLISTS" - else -> "Unknown $position" - }; - }; - tabLayoutMediator.attach(); - - _tabLayoutMediator = tabLayoutMediator; - _tabs = tabs; - _viewPager = viewPager; + viewPager.adapter = adapter + val tabLayoutMediator = TabLayoutMediator( + tabs, viewPager, (viewPager.adapter as ChannelViewPagerAdapter)::getTabNames + ) + tabLayoutMediator.attach() + _tabLayoutMediator = tabLayoutMediator + _tabs = tabs + _viewPager = viewPager if (_selectedTabIndex != -1) { - selectTab(_selectedTabIndex); + selectTab(_selectedTabIndex) } - - setLoading(true); + setLoading(true) } fun cleanup() { - _taskLoadPolycentricProfile.cancel(); - _taskGetChannel.cancel(); - _tabLayoutMediator.detach(); - _viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback); - hideSlideUpOverlay(); - (_overlay_loading_spinner.drawable as Animatable?)?.stop(); + _taskLoadPolycentricProfile.cancel() + _taskGetChannel.cancel() + _tabLayoutMediator.detach() + _viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback) + hideSlideUpOverlay() + (_overlayLoadingSpinner.drawable as Animatable?)?.stop() } fun onShown(parameter: Any?, isBack: Boolean) { - hideSlideUpOverlay(); - _taskLoadPolycentricProfile.cancel(); - _selectedTabIndex = -1; + hideSlideUpOverlay() + _taskLoadPolycentricProfile.cancel() + _selectedTabIndex = -1 if (!isBack || _url == null) { - _imageBanner.setImageDrawable(null); + _imageBanner.setImageDrawable(null) - if (parameter is String) { - _buttonSubscribe.setSubscribeChannel(parameter); - _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; - setPolycentricProfileOr(parameter) { - _textChannel.text = ""; - _textChannelSub.text = ""; - _creatorThumbnail.setThumbnail(null, true); - Glide.with(_imageBanner) - .clear(_imageBanner); - }; + when (parameter) { + is String -> { + _buttonSubscribe.setSubscribeChannel(parameter) + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + setPolycentricProfileOr(parameter) { + _textChannel.text = "" + _textChannelSub.text = "" + _creatorThumbnail.setThumbnail(null, true) + Glide.with(_imageBanner).clear(_imageBanner) + } - _url = parameter; - loadChannel(); - } else if (parameter is SerializedChannel) { - showChannel(parameter); - _url = parameter.url; - loadChannel(); - } else if (parameter is IPlatformChannel) - showChannel(parameter); - else if (parameter is PlatformAuthorLink) { - setPolycentricProfileOr(parameter.url) { - _textChannel.text = parameter.name; - _textChannelSub.text = ""; - _creatorThumbnail.setThumbnail(parameter.thumbnail, true); - Glide.with(_imageBanner) - .clear(_imageBanner); + _url = parameter + loadChannel() + } - loadPolycentricProfile(parameter.id, parameter.url) - }; + is SerializedChannel -> { + showChannel(parameter) + _url = parameter.url + loadChannel() + } - _url = parameter.url; - loadChannel(); - } else if (parameter is Subscription) { - setPolycentricProfileOr(parameter.channel.url) { - _textChannel.text = parameter.channel.name; - _textChannelSub.text = ""; - _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true); - Glide.with(_imageBanner) - .clear(_imageBanner); + is IPlatformChannel -> showChannel(parameter) + is PlatformAuthorLink -> { + setPolycentricProfileOr(parameter.url) { + _textChannel.text = parameter.name + _textChannelSub.text = "" + _creatorThumbnail.setThumbnail(parameter.thumbnail, true) + Glide.with(_imageBanner).clear(_imageBanner) - loadPolycentricProfile(parameter.channel.id, parameter.channel.url) - }; + loadPolycentricProfile(parameter.id, parameter.url) + } - _url = parameter.channel.url; - loadChannel(); + _url = parameter.url + loadChannel() + } + + is Subscription -> { + setPolycentricProfileOr(parameter.channel.url) { + _textChannel.text = parameter.channel.name + _textChannelSub.text = "" + _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true) + Glide.with(_imageBanner).clear(_imageBanner) + + loadPolycentricProfile(parameter.channel.id, parameter.channel.url) + } + + _url = parameter.channel.url + loadChannel() + } } } else { - loadChannel(); + loadChannel() } } fun selectTab(selectedTabIndex: Int) { - _selectedTabIndex = selectedTabIndex; - _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)); + _selectedTabIndex = selectedTabIndex + _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) } private fun loadPolycentricProfile(id: PlatformID, url: String) { - val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true); + val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true) if (cachedPolycentricProfile != null) { setPolycentricProfile(cachedPolycentricProfile, animate = true) if (cachedPolycentricProfile.expired) { - _taskLoadPolycentricProfile.run(id); + _taskLoadPolycentricProfile.run(id) } } else { - _taskLoadPolycentricProfile.run(id); + _taskLoadPolycentricProfile.run(id) } } private fun setLoading(isLoading: Boolean) { if (_isLoading == isLoading) { - return; + return } - _isLoading = isLoading; - if(isLoading){ - _overlay_loading.visibility = View.VISIBLE; - (_overlay_loading_spinner.drawable as Animatable?)?.start(); - } - else { - (_overlay_loading_spinner.drawable as Animatable?)?.stop(); - _overlay_loading.visibility = View.GONE; + _isLoading = isLoading + if (isLoading) { + _overlayLoading.visibility = View.VISIBLE + (_overlayLoadingSpinner.drawable as Animatable?)?.start() + } else { + (_overlayLoadingSpinner.drawable as Animatable?)?.stop() + _overlayLoading.visibility = View.GONE } } fun onBackPressed(): Boolean { if (_slideUpOverlay != null) { - hideSlideUpOverlay(); - return true; + hideSlideUpOverlay() + return true } - return false; + return false } private fun hideSlideUpOverlay() { - _slideUpOverlay?.hide(false); - _slideUpOverlay = null; + _slideUpOverlay?.hide(false) + _slideUpOverlay = null } private fun loadChannel() { - val url = _url; + val url = _url if (url != null) { - setLoading(true); - _taskGetChannel.run(url); + setLoading(true) + _taskGetChannel.run(url) } } private fun showChannel(channel: IPlatformChannel) { - setLoading(false); + setLoading(false) - if (!StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists) { - _tabs.removeTabAt(PLAYLIST_POSITION) - } - - _fragment.topBar?.onShown(channel); + _fragment.topBar?.onShown(channel) val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { - UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist).replace("{channelName}", channel.name), { - UIDialogs.showDialogProgress(context) { - _fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - StatePlaylists.instance.createPlaylistFromChannel(channel) { page -> - _fragment.lifecycleScope.launch(Dispatchers.Main) { - it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page"); + UIDialogs.showConfirmationDialog(context, + context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist) + .replace("{channelName}", channel.name), + { + UIDialogs.showDialogProgress(context) { + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + StatePlaylists.instance.createPlaylistFromChannel(channel) { page -> + _fragment.lifecycleScope.launch(Dispatchers.Main) { + it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page") + } } - }; - } - catch(ex: Exception) { - Logger.e(TAG, "Error", ex); - UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_convert_channel), ex); - } + } catch (ex: Exception) { + Logger.e(TAG, "Error", ex) + UIDialogs.showGeneralErrorDialog( + context, + context.getString(R.string.failed_to_convert_channel), + ex + ) + } - withContext(Dispatchers.Main) { - it.hide(); + withContext(Dispatchers.Main) { + it.hide() + } } - }; - }; - }); - }); + } + }) + }) _fragment.lifecycleScope.launch(Dispatchers.IO) { - val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url); + val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) withContext(Dispatchers.Main) { if (plugin != null && plugin.capabilities.hasSearchChannelContents) { buttons.add(Pair(R.drawable.ic_search) { - _fragment.navigate(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); - }); + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO, channel.url + ) + ) + }) - _fragment.topBar?.assume()?.setMenuItems(buttons); + _fragment.topBar?.assume()?.setMenuItems(buttons) } } } - _buttonSubscribe.setSubscribeChannel(channel); - _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; - _textChannel.text = channel.name; - _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else ""; + _buttonSubscribe.setSubscribeChannel(channel) + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + _textChannel.text = channel.name + _textChannelSub.text = + if (channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString( + R.string.subscribers + ).lowercase() else "" - //TODO: Find a better way to access the adapter fragments.. - - (_viewPager.adapter as ChannelViewPagerAdapter?)?.let { - it.getFragment().setChannel(channel); - 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 + val supportsPlaylists = + StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists + if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( + ChannelTab.PLAYLISTS.ordinal.toLong() + ) + ) { + (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.PLAYLISTS) + } + if (!supportsPlaylists && (_viewPager.adapter as ChannelViewPagerAdapter).containsItem( + ChannelTab.PLAYLISTS.ordinal.toLong() + ) + ) { + (_viewPager.adapter as ChannelViewPagerAdapter).remove(2) } - this.channel = channel; + // sets the channel for each tab + for (fragment in _fragment.childFragmentManager.fragments) { + (fragment as IChannelTabFragment).setChannel(channel) + } + + (_viewPager.adapter as ChannelViewPagerAdapter).channel = channel + + + _viewPager.adapter!!.notifyDataSetChanged() + + this.channel = channel setPolycentricProfileOr(channel.url) { - _textChannel.text = channel.name; - _creatorThumbnail.setThumbnail(channel.thumbnail, true); - Glide.with(_imageBanner) - .load(channel.banner) - .crossfade() - .into(_imageBanner); + _textChannel.text = channel.name + _creatorThumbnail.setThumbnail(channel.thumbnail, true) + Glide.with(_imageBanner).load(channel.banner).crossfade().into(_imageBanner) - _taskLoadPolycentricProfile.run(channel.id); - }; + _taskLoadPolycentricProfile.run(channel.id) + } } private fun setPolycentricProfileOr(url: String, or: () -> Unit) { - setPolycentricProfile(null, animate = false); + setPolycentricProfile(null, animate = false) - val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }; + val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) } if (cachedProfile != null) { - setPolycentricProfile(cachedProfile, animate = false); + setPolycentricProfile(cachedProfile, animate = false) } else { - or(); + or() } } - private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_35 = 35.dp(resources) - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(channel?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); + private fun setPolycentricProfile( + cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean + ) { + val dp35 = 35.dp(resources) + val profile = cachedPolycentricProfile?.profile + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let { + it.toURLInfoSystemLinkUrl( + profile.system.toProto(), it.process, profile.systemState.servers.toList() + ) } - val banner = profile?.systemState?.banner?.selectHighestResolutionImage() - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; + if (avatar != null) { + _creatorThumbnail.setThumbnail(avatar, animate) + } else { + _creatorThumbnail.setThumbnail(channel?.thumbnail, animate) + _creatorThumbnail.setHarborAvailable( + profile != null, animate, profile?.system?.toProto() + ) + } + + val banner = profile?.systemState?.banner?.selectHighestResolutionImage()?.let { + it.toURLInfoSystemLinkUrl( + profile.system.toProto(), it.process, profile.systemState.servers.toList() + ) + } if (banner != null) { - Glide.with(_imageBanner) - .load(banner) - .crossfade() - .into(_imageBanner); + Glide.with(_imageBanner).load(banner).crossfade().into(_imageBanner) } else { - Glide.with(_imageBanner) - .load(channel?.banner) - .crossfade() - .into(_imageBanner); + Glide.with(_imageBanner).load(channel?.banner).crossfade().into(_imageBanner) } if (profile != null) { - _fragment.topBar?.onShown(profile); - _textChannel.text = profile.systemState.username; + _fragment.topBar?.onShown(profile) + _textChannel.text = profile.systemState.username } - (_viewPager.adapter as ChannelViewPagerAdapter?)?.let { - it.getFragment().setPolycentricProfile(profile); - 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 + // sets the profile for each tab + for (fragment in _fragment.childFragmentManager.fragments) { + (fragment as IChannelTabFragment).setPolycentricProfile(profile) } + + //TODO only add channels and support if its setup on the polycentric profile + if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( + ChannelTab.SUPPORT.ordinal.toLong() + ) + ) { + (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.SUPPORT) + } + if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( + ChannelTab.CHANNELS.ordinal.toLong() + ) + ) { + (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.CHANNELS) + } + (_viewPager.adapter as ChannelViewPagerAdapter).profile = profile + _viewPager.adapter!!.notifyDataSetChanged() } } companion object { - val TAG = "ChannelFragment"; + const val TAG = "ChannelFragment" fun newInstance() = ChannelFragment().apply { } } } 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 e908084b..e0618cdd 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 @@ -5,81 +5,117 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter 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.constructs.Event1 import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.fragment.channel.tab.* +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.channel.tab.IChannelTabFragment +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.google.android.material.tabs.TabLayout -class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) { - private val _cache: Array = arrayOfNulls(5); - val onContentUrlClicked = Event2(); - val onUrlClicked = Event1(); - val onContentClicked = Event2(); - val onChannelClicked = Event1(); - val onAddToClicked = Event1(); - val onAddToQueueClicked = Event1(); - val onAddToWatchLaterClicked = Event1(); - val onLongPress = Event1(); +enum class ChannelTab { + VIDEOS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT +} + +class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : + FragmentStateAdapter(fragmentManager, lifecycle) { + private val _supportedFragments = mutableMapOf( + ChannelTab.VIDEOS.ordinal to ChannelTab.VIDEOS, ChannelTab.ABOUT.ordinal to ChannelTab.ABOUT + ) + private val _tabs = arrayListOf(ChannelTab.VIDEOS, ChannelTab.ABOUT) + + var profile: PolycentricProfile? = null + var channel: IPlatformChannel? = null + + val onContentUrlClicked = Event2() + val onUrlClicked = Event1() + val onContentClicked = Event2() + val onChannelClicked = Event1() + val onAddToClicked = Event1() + val onAddToQueueClicked = Event1() + val onAddToWatchLaterClicked = Event1() + val onLongPress = Event1() + + override fun getItemId(position: Int): Long { + return _tabs[position].ordinal.toLong() + } + + override fun containsItem(itemId: Long): Boolean { + return _supportedFragments.containsKey(itemId.toInt()) + } override fun getItemCount(): Int { - return _cache.size; + return _supportedFragments.size } - inline fun getFragment(): T { - //TODO: I have a feeling this can somehow be synced with createFragment so only 1 mapping exists (without a Map<>) - if(T::class == ChannelContentsFragment::class) - return createFragment(0) as T; - else if(T::class == ChannelListFragment::class) - return createFragment(1) as T; - //else if(T::class == ChannelStoreFragment::class) - // return createFragment(2) as T; - else if(T::class == ChannelMonetizationFragment::class) - 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"); + fun getTabNames(tab: TabLayout.Tab, position: Int) { + tab.text = _tabs[position].name + } + + fun insert(position: Int, tab: ChannelTab) { + _supportedFragments[tab.ordinal] = tab + _tabs.add(position, tab) + notifyItemInserted(position) + } + + fun remove(position: Int) { + _supportedFragments.remove(_tabs[position].ordinal) + _tabs.removeAt(position) + notifyItemRemoved(position) } override fun createFragment(position: Int): Fragment { - val cachedFragment = _cache[position]; - if (cachedFragment != null) { - return cachedFragment; + val fragment: Fragment + when (_tabs[position]) { + ChannelTab.VIDEOS -> { + fragment = ChannelContentsFragment.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) + } + } + + ChannelTab.CHANNELS -> { + fragment = ChannelListFragment.newInstance() + .apply { onClickChannel.subscribe(onChannelClicked::emit) } + } + + ChannelTab.PLAYLISTS -> { + fragment = 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) + } + } + + ChannelTab.SUPPORT -> { + fragment = ChannelMonetizationFragment.newInstance() + } + + ChannelTab.ABOUT -> { + fragment = ChannelAboutFragment.newInstance() + } } + channel?.let { (fragment as IChannelTabFragment).setChannel(it) } + profile?.let { (fragment as IChannelTabFragment).setPolycentricProfile(it) } - val fragment = when (position) { - 0 -> ChannelContentsFragment.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); - }; - 1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) }; - //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") - }; - - _cache[position]= fragment; - return fragment; + return fragment } } \ No newline at end of file From ef284ba51d05586fe5a85168bd484d4dc3acb0a9 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Wed, 5 Jun 2024 13:44:05 -0500 Subject: [PATCH 05/28] fixed tab changing when adding the playlist tab --- .../mainactivity/main/ChannelFragment.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 f6126c58..8015574e 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 @@ -459,17 +459,31 @@ class ChannelFragment : MainFragment() { val supportsPlaylists = StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists + val playlistPosition = 2 if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( ChannelTab.PLAYLISTS.ordinal.toLong() ) ) { - (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.PLAYLISTS) + // keep the current tab selected + if (_viewPager.currentItem >= playlistPosition) { + _viewPager.setCurrentItem(_viewPager.currentItem + 1, false) + } + + (_viewPager.adapter as ChannelViewPagerAdapter).insert( + playlistPosition, + ChannelTab.PLAYLISTS + ) } if (!supportsPlaylists && (_viewPager.adapter as ChannelViewPagerAdapter).containsItem( ChannelTab.PLAYLISTS.ordinal.toLong() ) ) { - (_viewPager.adapter as ChannelViewPagerAdapter).remove(2) + // keep the current tab selected + if (_viewPager.currentItem >= playlistPosition) { + _viewPager.setCurrentItem(_viewPager.currentItem - 1, false) + } + + (_viewPager.adapter as ChannelViewPagerAdapter).remove(playlistPosition) } // sets the channel for each tab From 4cf3aabe89e285c4af0b6176be356329f32b12ec Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Wed, 5 Jun 2024 18:57:43 -0500 Subject: [PATCH 06/28] removed additional hardcoding --- .../fragment/mainactivity/main/ChannelFragment.kt | 14 ++++++++++---- .../mainactivity/main/PostDetailFragment.kt | 3 ++- .../views/adapters/ChannelViewPagerAdapter.kt | 4 ++++ 3 files changed, 16 insertions(+), 5 deletions(-) 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 8015574e..97165845 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 @@ -95,8 +95,8 @@ class ChannelFragment : MainFragment() { _view = null } - fun selectTab(selectedTabIndex: Int) { - _view?.selectTab(selectedTabIndex) + fun selectTab(tab: ChannelTab) { + _view?.selectTab(tab) } @SuppressLint("ViewConstructor") @@ -268,6 +268,10 @@ class ChannelFragment : MainFragment() { setLoading(true) } + fun selectTab(tab: ChannelTab) { + (_viewPager.adapter as ChannelViewPagerAdapter).getTabPosition(tab) + } + fun cleanup() { _taskLoadPolycentricProfile.cancel() _taskGetChannel.cancel() @@ -560,18 +564,20 @@ class ChannelFragment : MainFragment() { (fragment as IChannelTabFragment).setPolycentricProfile(profile) } + val insertPosition = 2 + //TODO only add channels and support if its setup on the polycentric profile if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( ChannelTab.SUPPORT.ordinal.toLong() ) ) { - (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.SUPPORT) + (_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.SUPPORT) } if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( ChannelTab.CHANNELS.ordinal.toLong() ) ) { - (_viewPager.adapter as ChannelViewPagerAdapter).insert(2, ChannelTab.CHANNELS) + (_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.CHANNELS) } (_viewPager.adapter as ChannelViewPagerAdapter).profile = profile _viewPager.adapter!!.notifyDataSetChanged() diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index 22ad5e93..df09b741 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -41,6 +41,7 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.ChannelTab import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.others.CreatorThumbnail @@ -264,7 +265,7 @@ class PostDetailFragment : MainFragment { _buttonSupport.setOnClickListener { val author = _post?.author ?: _postOverview?.author; - author?.let { _fragment.navigate(it).selectTab(2); }; + author?.let { _fragment.navigate(it).selectTab(ChannelTab.SUPPORT); }; }; _buttonStore.setOnClickListener { 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 e0618cdd..8bb2b946 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 @@ -55,6 +55,10 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec return _supportedFragments.size } + fun getTabPosition(tab: ChannelTab): Int { + return _tabs.indexOf(tab) + } + fun getTabNames(tab: TabLayout.Tab, position: Int) { tab.text = _tabs[position].name } From 2a7c0a5c7962789580ce31e900135c14833b869d Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 6 Jun 2024 20:07:13 +0200 Subject: [PATCH 07/28] Changed share intent. --- .../activities/PolycentricBackupActivity.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 3326c068..a0a0fac1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Color +import android.net.Uri import android.os.Bundle import android.util.TypedValue import android.view.View @@ -19,7 +20,12 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.buttons.BigButton -import com.futo.polycentric.core.* +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.SignedEvent +import com.futo.polycentric.core.StorageTypeCRDTItem +import com.futo.polycentric.core.StorageTypeCRDTSetItem +import com.futo.polycentric.core.Store +import com.futo.polycentric.core.toBase64Url import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.google.zxing.common.BitMatrix @@ -64,11 +70,8 @@ class PolycentricBackupActivity : AppCompatActivity() { } _buttonShare.onClick.subscribe { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain"; - putExtra(Intent.EXTRA_TEXT, _exportBundle); - } - startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text))); + val shareIntent = Intent(Intent.ACTION_VIEW, Uri.parse(_exportBundle)) + startActivity(Intent.createChooser(shareIntent, "Share ID")); }; _buttonCopy.onClick.subscribe { From fc5a8d9531fd68a625acec13a4987941291e39c8 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Thu, 6 Jun 2024 17:33:35 -0500 Subject: [PATCH 08/28] disable download button for widevine sources --- .../main/java/com/futo/platformplayer/helpers/VideoHelper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 9ca4aa8e..df4cf4eb 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource 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 @@ -44,7 +45,7 @@ class VideoHelper { } fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource; - fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource; + fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource) && source !is IAudioUrlWidevineSource fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { From a345da0feba51b23d8486e1b6c19ae74858adc2b Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 7 Jun 2024 09:32:16 +0200 Subject: [PATCH 09/28] Added spotify plugin. Fixed bilibili signing. Added bilibili and spotify link handling. --- .gitmodules | 6 +++++ app/src/stable/AndroidManifest.xml | 6 +++++ app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/patreon | 2 +- app/src/stable/assets/sources/spotify | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/AndroidManifest.xml | 6 +++++ app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/patreon | 2 +- app/src/unstable/assets/sources/spotify | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- sign-all-sources.sh | 30 ++++++++++++--------- 12 files changed, 46 insertions(+), 18 deletions(-) create mode 160000 app/src/stable/assets/sources/spotify create mode 160000 app/src/unstable/assets/sources/spotify diff --git a/.gitmodules b/.gitmodules index 2e67308c..cfc1f2fa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -64,3 +64,9 @@ [submodule "app/src/stable/assets/sources/bilibili"] path = app/src/stable/assets/sources/bilibili url = ../plugins/bilibili.git +[submodule "app/src/stable/assets/sources/spotify"] + path = app/src/stable/assets/sources/spotify + url = ../plugins/spotify.git +[submodule "app/src/unstable/assets/sources/spotify"] + path = app/src/unstable/assets/sources/spotify + url = ../plugins/spotify.git diff --git a/app/src/stable/AndroidManifest.xml b/app/src/stable/AndroidManifest.xml index 0f4a00b8..a5fdd260 100644 --- a/app/src/stable/AndroidManifest.xml +++ b/app/src/stable/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + @@ -51,6 +54,9 @@ + + + diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 611f692c..b518be4d 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 611f692ced94bac637907b105170a4143580281a +Subproject commit b518be4dd5e162e67e9ca64e09be3fe574fccdb7 diff --git a/app/src/stable/assets/sources/patreon b/app/src/stable/assets/sources/patreon index cee1fda4..5b191993 160000 --- a/app/src/stable/assets/sources/patreon +++ b/app/src/stable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit cee1fda4e875a46315a9d4492e2e3b541d98f39f +Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify new file mode 160000 index 00000000..843cf2dc --- /dev/null +++ b/app/src/stable/assets/sources/spotify @@ -0,0 +1 @@ +Subproject commit 843cf2dc4b19c02d86a00407e2e8f9f1048cf475 diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index f679a30a..a1da4004 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -9,7 +9,8 @@ "4a78c2ff-c20f-43ac-8f75-34515df1d320": "sources/kick/KickConfig.json", "aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json", "9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json", - "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json" + "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json", + "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 8a600a38..60fe5061 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + @@ -51,6 +54,9 @@ + + + diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 611f692c..b518be4d 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 611f692ced94bac637907b105170a4143580281a +Subproject commit b518be4dd5e162e67e9ca64e09be3fe574fccdb7 diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index cee1fda4..5b191993 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit cee1fda4e875a46315a9d4492e2e3b541d98f39f +Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify new file mode 160000 index 00000000..843cf2dc --- /dev/null +++ b/app/src/unstable/assets/sources/spotify @@ -0,0 +1 @@ +Subproject commit 843cf2dc4b19c02d86a00407e2e8f9f1048cf475 diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index db526f8f..551c5470 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -9,7 +9,8 @@ "4a78c2ff-c20f-43ac-8f75-34515df1d320": "sources/kick/KickConfig.json", "aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json", "9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json", - "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json" + "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json", + "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/sign-all-sources.sh b/sign-all-sources.sh index ff4c0de6..3a6d783f 100755 --- a/sign-all-sources.sh +++ b/sign-all-sources.sh @@ -1,21 +1,27 @@ #!/bin/bash -# Array of directories to look in dirs=("app/src/unstable/assets/sources" "app/src/stable/assets/sources") -# Loop through each directory +sign_scripts() { + local plugin_dir=$1 + + if [[ -d "$plugin_dir" ]]; then + script_file=$(find "$plugin_dir" -maxdepth 2 -name '*Script.js') + config_file=$(find "$plugin_dir" -maxdepth 2 -name '*Config.json') + sign_script="$plugin_dir/sign.sh" + + if [[ -f "$sign_script" && -n "$script_file" && -n "$config_file" ]]; then + sh "$sign_script" "$script_file" "$config_file" + fi + fi +} + for dir in "${dirs[@]}"; do - if [[ -d "$dir" ]]; then # Check if directory exists - for plugin in "$dir"/*; do # Loop through each plugin folder + if [[ -d "$dir" ]]; then + for plugin in "$dir"/*; do if [[ -d "$plugin" ]]; then - script_file=$(find "$plugin" -maxdepth 1 -name '*Script.js') - config_file=$(find "$plugin" -maxdepth 1 -name '*Config.json') - sign_script="$plugin/sign.sh" - - if [[ -f "$sign_script" && -n "$script_file" && -n "$config_file" ]]; then - sh "$sign_script" "$script_file" "$config_file" - fi + sign_scripts "$plugin" fi done fi -done +done \ No newline at end of file From a0d6ff912b081ad2a4ff56f60ff0378317e64b99 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 7 Jun 2024 12:52:25 +0200 Subject: [PATCH 10/28] App version info for plugins, trust all certs dev setting, latest refs --- .../com/futo/platformplayer/SettingsDev.kt | 14 ++++++++++ .../api/http/ManagedHttpClient.kt | 28 +++++++++++++++++++ .../engine/packages/PackageBridge.kt | 15 ++++++++++ app/src/main/res/values/strings.xml | 3 ++ app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/patreon | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/AndroidManifest.xml | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/patreon | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 11 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt index 91bba5e4..aa5e4c94 100644 --- a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt +++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt @@ -33,6 +33,7 @@ import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.views.fields.ButtonField import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField +import com.futo.platformplayer.views.fields.FormFieldWarning import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -493,6 +494,17 @@ class SettingsDev : FragmentedStorageFileJson() { } + + @FormField(R.string.networking, FieldForm.GROUP, -1, 18) + var networking = Networking(); + @Serializable + class Networking { + @FormField(R.string.allow_all_certificates, FieldForm.TOGGLE, -1, 0) + @FormFieldWarning(R.string.allow_all_certificates_warning) + var allowAllCertificates: Boolean = false; + } + + @Contextual @Transient @FormField(R.string.info, FieldForm.GROUP, -1, 19) @@ -503,6 +515,8 @@ class SettingsDev : FragmentedStorageFileJson() { var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount; } + + //region BOILERPLATE override fun encode(): String { return Json.encodeToString(this); diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt index 9c79b665..5121f41e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.api.http +import androidx.collection.arrayMapOf +import com.futo.platformplayer.SettingsDev import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger @@ -13,6 +15,11 @@ import okhttp3.Response import okhttp3.ResponseBody import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager import kotlin.system.measureTimeMillis open class ManagedHttpClient { @@ -25,8 +32,29 @@ open class ManagedHttpClient { var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + private val trustAllCerts = arrayOf( + object: X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) { } + override fun checkServerTrusted(chain: Array?, authType: String?) { } + override fun getAcceptedIssuers(): Array { + return arrayOf(); + } + } + ); + private fun trustAllCertificates(builder: OkHttpClient.Builder) { + val sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, trustAllCerts, SecureRandom()); + builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager); + builder.hostnameVerifier { a, b -> + return@hostnameVerifier true; + } + Logger.w(TAG, "Creating INSECURE client (TrustAll)"); + } + constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { _builderTemplate = builder; + if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates) + trustAllCertificates(builder); client = builder.addNetworkInterceptor { chain -> val request = beforeRequest(chain.request()); val response = afterRequest(chain.proceed(request)); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index d07720e8..44464b0d 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -1,6 +1,8 @@ package com.futo.platformplayer.engine.packages import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.annotations.V8Property +import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs @@ -35,6 +37,19 @@ class PackageBridge : V8Package { _clientAuth = plugin.httpClientAuth; } + + @V8Property + fun buildVersion(): Int { + //If debug build, assume max version + if(BuildConfig.VERSION_CODE == 1) + return Int.MAX_VALUE; + return BuildConfig.VERSION_CODE; + } + @V8Property + fun buildFlavor(): String { + return BuildConfig.FLAVOR; + } + @V8Function fun toast(str: String) { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 786fbd99..4cba9f1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -347,6 +347,7 @@ Get answers to common questions Give feedback on the application Info + Networking Gesture controls Volume slider Enable slide gesture to change volume @@ -461,6 +462,8 @@ Deletes all ongoing downloads Deletes all unresolved source files Developer Mode + Allow All Certificates + This risks exposing all your Grayjay network traffic. Development Server Experimental Cache diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 611f692c..b518be4d 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 611f692ced94bac637907b105170a4143580281a +Subproject commit b518be4dd5e162e67e9ca64e09be3fe574fccdb7 diff --git a/app/src/stable/assets/sources/patreon b/app/src/stable/assets/sources/patreon index cee1fda4..5b191993 160000 --- a/app/src/stable/assets/sources/patreon +++ b/app/src/stable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit cee1fda4e875a46315a9d4492e2e3b541d98f39f +Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 37e2ed94..c23302da 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 37e2ed94384ff82f4cb67a2250877cb1e8e03c57 +Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 8a600a38..0f4a00b8 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -4,7 +4,7 @@ - + diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 611f692c..b518be4d 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 611f692ced94bac637907b105170a4143580281a +Subproject commit b518be4dd5e162e67e9ca64e09be3fe574fccdb7 diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index cee1fda4..5b191993 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit cee1fda4e875a46315a9d4492e2e3b541d98f39f +Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index d0cca1ac..c23302da 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit d0cca1ac04a1812414ee633a08585dc896701a32 +Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 From ebb469342558b19f61f76d586a18567a584aff30 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Fri, 7 Jun 2024 09:53:19 -0500 Subject: [PATCH 11/28] adjust tab order --- .../fragment/mainactivity/main/ChannelFragment.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 97165845..dc419673 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 @@ -345,7 +345,7 @@ class ChannelFragment : MainFragment() { } } - fun selectTab(selectedTabIndex: Int) { + private fun selectTab(selectedTabIndex: Int) { _selectedTabIndex = selectedTabIndex _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) } @@ -463,7 +463,7 @@ class ChannelFragment : MainFragment() { val supportsPlaylists = StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists - val playlistPosition = 2 + val playlistPosition = 1 if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( ChannelTab.PLAYLISTS.ordinal.toLong() ) @@ -564,7 +564,7 @@ class ChannelFragment : MainFragment() { (fragment as IChannelTabFragment).setPolycentricProfile(profile) } - val insertPosition = 2 + val insertPosition = 1 //TODO only add channels and support if its setup on the polycentric profile if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( From 80a6a8ac9f93278f8f7a28e2b37c4d308d47a898 Mon Sep 17 00:00:00 2001 From: Ian Mason Date: Fri, 7 Jun 2024 23:34:03 +0000 Subject: [PATCH 12/28] Update LICENSE.md --- LICENSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index f3d7e742..38414394 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -7,7 +7,7 @@ By using the software, you agree to all of the terms and conditions below. FUTO Holdings, Inc. (the “Licensor”) grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations below. ## Limitations -You may use or modify the software for only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application. +You may use or modify the software only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application. You may distribute the software or provide it to others only if you do so free of charge for non-commercial purposes. From 9499afd81501c8bcc82697a5578427e4462e2b61 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 10 Jun 2024 12:57:17 +0200 Subject: [PATCH 13/28] Twitch refs --- app/src/stable/assets/sources/twitch | 2 +- app/src/unstable/assets/sources/twitch | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/twitch b/app/src/stable/assets/sources/twitch index 8d978dd7..b4696e4e 160000 --- a/app/src/stable/assets/sources/twitch +++ b/app/src/stable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57 +Subproject commit b4696e4e2e6dad821a89625dc68e11dd67ff2d31 diff --git a/app/src/unstable/assets/sources/twitch b/app/src/unstable/assets/sources/twitch index 8d978dd7..b4696e4e 160000 --- a/app/src/unstable/assets/sources/twitch +++ b/app/src/unstable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57 +Subproject commit b4696e4e2e6dad821a89625dc68e11dd67ff2d31 From ff28a07871c39f0f7d2df3bc31745a1f71f9ab21 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 13 Jun 2024 11:21:48 +0200 Subject: [PATCH 14/28] Fix loop offline videos, make loop not reload video --- .../fragment/mainactivity/main/VideoDetailView.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 07ba3e7f..6d81df24 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1063,6 +1063,11 @@ class VideoDetailView : ConstraintLayout { if(!bypassSameVideoCheck && this.video?.url == video.url) return; + //Loop workaround + if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) { + _player.seekTo(0); + return; + } val cachedVideo = StateDownloads.instance.getCachedVideo(video.id); if(cachedVideo != null) { From aad50e7b503b50ca174aab81803bdbb268ae64d9 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 13 Jun 2024 13:45:31 +0200 Subject: [PATCH 15/28] Improved playlist import support --- .../main/ImportPlaylistsFragment.kt | 41 +++++++++++++++---- .../mainactivity/main/PlaylistFragment.kt | 6 +-- .../viewholders/ImportPlaylistsViewHolder.kt | 7 ++-- app/src/main/res/layout/dialog_progress.xml | 1 + app/src/unstable/assets/sources/youtube | 2 +- 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt index e90a535d..fbfe75c8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt @@ -12,16 +12,21 @@ import android.widget.TextView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.AnyAdapterView import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.adapters.viewholders.ImportPlaylistsViewHolder import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ImportPlaylistsFragment : MainFragment() { override val isMainView : Boolean = true; @@ -67,7 +72,7 @@ class ImportPlaylistsFragment : MainFragment() { private val _items: ArrayList = arrayListOf(); private var _currentLoadIndex = 0; - private var _taskLoadPlaylist: TaskHandler; + private var _taskLoadPlaylist: TaskHandler; constructor(fragment: ImportPlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { _fragment = fragment; @@ -102,7 +107,7 @@ class ImportPlaylistsFragment : MainFragment() { setLoading(false); - _taskLoadPlaylist = TaskHandler({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link).toPlaylist(); }) + _taskLoadPlaylist = TaskHandler({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link); }) .success { if (it != null) { _items.add(SelectablePlaylist(it)); @@ -113,7 +118,7 @@ class ImportPlaylistsFragment : MainFragment() { }.exceptionWithParameter { ex, para -> //setLoading(false); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); - UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false) + UIDialogs.appToast(context.getString(R.string.failed_to_fetch) + "\n${para}\n" + ex.message, false) //UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); loadNext(); }; @@ -147,12 +152,32 @@ class ImportPlaylistsFragment : MainFragment() { it.title = context.getString(R.string.import_playlists); it.onImport.subscribe(this) { val playlistsToImport = _items.filter { i -> i.selected }.toList(); - for (playlistToImport in playlistsToImport) { - StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist); - } - UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported)); - _fragment.closeSegment(); + UIDialogs.showDialogProgress(context) { + it.setText("Importing playlists.."); + it.setProgress(0f); + _fragment.lifecycleScope.launch(Dispatchers.IO) { + for ((i, playlistToImport) in playlistsToImport.withIndex()) { + withContext(Dispatchers.Main) { + it.setText("Importing playlists..\n[${playlistToImport.playlist.name}]"); + } + try { + StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist.toPlaylist()); + } + catch(ex: Throwable) { + UIDialogs.appToast("Failed to import [${playlistToImport.playlist.name}]\n" + ex.message); + } + withContext(Dispatchers.Main) { + it.setProgress(i.toDouble() / playlistsToImport.size); + } + } + withContext(Dispatchers.Main) { + UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported)); + _fragment.closeSegment(); + it.dismiss(); + } + } + } }; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index d938e970..713668fe 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -136,12 +136,12 @@ class PlaylistFragment : MainFragment() { return@TaskHandler StatePlatform.instance.getPlaylist(it); }) .success { - setLoading(false); _remotePlaylist = it; setName(it.name); - setVideos(it.contents.getResults(), false); - setVideoCount(it.videoCount); //TODO: Implement support for pagination + setVideos(it.toPlaylist().videos, false); + setVideoCount(it.videoCount); + setLoading(false); } .exception { Logger.w(TAG, "Failed to load playlist.", it); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt index 9cbf400c..e17e84aa 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt @@ -7,6 +7,7 @@ import android.widget.LinearLayout import android.widget.TextView import com.bumptech.glide.Glide import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.views.adapters.AnyAdapter @@ -45,10 +46,10 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. override fun bind(value: SelectablePlaylist) { _textName.text = value.playlist.name; - _textMetadata.text = "${value.playlist.videos.size} " + _view.context.getString(R.string.videos); + _textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos); _checkbox.value = value.selected; - val thumbnail = value.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(); + val thumbnail = value.playlist.thumbnail; if (thumbnail != null) Glide.with(_imageThumbnail) .load(thumbnail) @@ -62,6 +63,6 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. } class SelectablePlaylist( - val playlist: Playlist, + val playlist: IPlatformPlaylistDetails, var selected: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml index 75b37e42..7bcc6228 100644 --- a/app/src/main/res/layout/dialog_progress.xml +++ b/app/src/main/res/layout/dialog_progress.xml @@ -45,6 +45,7 @@ android:textColor="@color/white" android:textSize="14dp" android:fontFamily="@font/inter_regular" + android:textAlignment="center" android:layout_marginTop="30dp" android:layout_marginStart="30dp" android:layout_marginEnd="30dp" /> diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index c23302da..2a38e0ce 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 +Subproject commit 2a38e0cecc635df2122b142f2d0c9e13ac894772 From ae904b4cd863906d6a29f2cf235221520a23fee1 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 13 Jun 2024 17:46:22 +0200 Subject: [PATCH 16/28] Content recommendation api support, removing old CachedPlatformClient --- .../api/media/CachedPlatformClient.kt | 109 ------------------ .../api/media/IPlatformClient.kt | 5 + .../api/media/PlatformClientCapabilities.kt | 3 +- .../contents/IPlatformContentDetails.kt | 2 + .../video/SerializedPlatformVideoDetails.kt | 2 + .../api/media/platforms/js/JSClient.kt | 18 ++- .../platforms/js/models/JSPostDetails.kt | 21 ++++ .../platforms/js/models/JSVideoDetails.kt | 21 ++++ .../platformplayer/downloads/VideoLocal.kt | 3 + .../mainactivity/main/TutorialFragment.kt | 7 +- .../platformplayer/states/StatePlatform.kt | 9 ++ 11 files changed, 84 insertions(+), 116 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt diff --git a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt deleted file mode 100644 index c604a7e6..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.futo.platformplayer.api.media - -import androidx.collection.LruCache -import com.futo.platformplayer.api.media.models.ResultCapabilities -import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.api.media.models.chapters.IChapter -import com.futo.platformplayer.api.media.models.comments.IPlatformComment -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor -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.structures.IPager -import com.futo.platformplayer.models.ImageVariable - -/** - * A temporary class that caches video results - * In future this should be part of a bigger system - */ -class CachedPlatformClient : IPlatformClient { - private val _client : IPlatformClient; - override val id: String get() = _client.id; - override val name: String get() = _client.name; - override val icon: ImageVariable? get() = _client.icon; - - private val _cache: LruCache; - - override val capabilities: PlatformClientCapabilities - get() = _client.capabilities; - - constructor(client : IPlatformClient, cacheSize : Int = 10 * 1024 * 1024) { - this._client = client; - this._cache = LruCache(cacheSize); - } - override fun initialize() { _client.initialize() } - override fun disable() { _client.disable() } - - override fun isContentDetailsUrl(url: String): Boolean = _client.isContentDetailsUrl(url); - override fun getContentDetails(url: String): IPlatformContentDetails { - var result = _cache.get(url); - if(result == null) { - result = _client.getContentDetails(url); - _cache.put(url, result); - } - return result; - } - - override fun getContentChapters(url: String): List = _client.getContentChapters(url); - override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url); - - override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url); - override fun getChannel(channelUrl: String): IPlatformChannel = _client.getChannel(channelUrl); - - override fun getChannelCapabilities(): ResultCapabilities = _client.getChannelCapabilities(); - override fun getChannelContents( - channelUrl: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.getChannelContents(channelUrl); - - override fun getChannelPlaylists(channelUrl: String): IPager = _client.getChannelPlaylists(channelUrl); - - override fun getPeekChannelTypes(): List = _client.getPeekChannelTypes(); - override fun peekChannelContents(channelUrl: String, type: String?): List = _client.peekChannelContents(channelUrl, type); - - override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? = _client.getChannelUrlByClaim(claimType, claimValues) - - override fun searchSuggestions(query: String): Array = _client.searchSuggestions(query); - override fun getSearchCapabilities(): ResultCapabilities = _client.getSearchCapabilities(); - override fun search( - query: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.search(query, type, order, filters); - - override fun getSearchChannelContentsCapabilities(): ResultCapabilities = _client.getSearchChannelContentsCapabilities(); - override fun searchChannelContents( - channelUrl: String, - query: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.searchChannelContents(channelUrl, query, type, order, filters); - - override fun searchChannels(query: String) = _client.searchChannels(query); - - override fun getComments(url: String): IPager = _client.getComments(url); - override fun getSubComments(comment: IPlatformComment): IPager = _client.getSubComments(comment); - - override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = _client.getLiveChatWindow(url); - override fun getLiveEvents(url: String): IPager? = _client.getLiveEvents(url); - - override fun getHome(): IPager = _client.getHome(); - - override fun getUserSubscriptions(): Array { return arrayOf(); }; - - override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = _client.searchPlaylists(query, type, order, filters); - override fun isPlaylistUrl(url: String): Boolean = _client.isPlaylistUrl(url); - override fun getPlaylist(url: String): IPlatformPlaylistDetails = _client.getPlaylist(url); - override fun getUserPlaylists(): Array { return arrayOf(); }; - - override fun isClaimTypeSupported(claimType: Int): Boolean { - return _client.isClaimTypeSupported(claimType); - } -} \ No newline at end of file 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 9aaf08c4..590ecc32 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 @@ -121,6 +121,11 @@ interface IPlatformClient { */ fun getPlaybackTracker(url: String): IPlaybackTracker?; + /** + * Get content recommendations + */ + fun getContentRecommendations(url: String): IPager?; + //Comments /** diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt index 1b76ae28..cb62b66c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -19,7 +19,8 @@ data class PlatformClientCapabilities( val hasGetLiveChatWindow: Boolean = false, val hasGetContentChapters: Boolean = false, val hasPeekChannelContents: Boolean = false, - val hasGetChannelPlaylists: Boolean = false + val hasGetChannelPlaylists: Boolean = false, + val hasGetContentRecommendations: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt index 781e4665..0f642764 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt @@ -10,4 +10,6 @@ interface IPlatformContentDetails : IPlatformContent { fun getComments(client: IPlatformClient): IPager?; fun getPlaybackTracker(): IPlaybackTracker?; + + fun getContentRecommendations(client: IPlatformClient): IPager?; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt index 39851441..1a0435d2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt @@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.sources.* @@ -56,6 +57,7 @@ open class SerializedPlatformVideoDetails( override fun getComments(client: IPlatformClient): IPager? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; companion object { fun fromVideo(video : IPlatformVideoDetails, subtitleSources: List) : SerializedPlatformVideoDetails { 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 aeec0ea3..0c3288fb 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 @@ -560,7 +560,7 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})")); } - @JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream") + @JSDocs(18, "source.getLiveChatWindow(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") { if(!capabilities.hasGetLiveChatWindow) @@ -569,7 +569,7 @@ open class JSClient : IPlatformClient { return@isBusyWith JSLiveChatWindowDescriptor(config, plugin.executeTyped("source.getLiveChatWindow(${Json.encodeToString(url)})")); } - @JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream") + @JSDocs(19, "source.getLiveEvents(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") override fun getLiveEvents(url: String): IPager? = isBusyWith("getLiveEvents") { if(!capabilities.hasGetLiveEvents) @@ -578,6 +578,20 @@ open class JSClient : IPlatformClient { return@isBusyWith JSLiveEventPager(config, this, plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})")); } + + + @JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page") + @JSDocsParameter("url", "Url of content") + override fun getContentRecommendations(url: String): IPager? = isBusyWith("getContentRecommendations") { + if(!capabilities.hasGetContentRecommendations) + return@isBusyWith null; + ensureEnabled(); + return@isBusyWith JSContentPager(config, this, + plugin.executeTyped("source.getContentRecommendations(${Json.encodeToString(url)})")); + } + + + @JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform") @JSDocsParameter("query", "Query that search results should match") @JSDocsParameter("type", "(optional) Type of contents to get from search ") diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt index bc455fcc..6c80d7dc 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails @@ -18,6 +19,7 @@ import com.futo.platformplayer.states.StateDeveloper class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { private val _hasGetComments: Boolean; + private val _hasGetContentRecommendations: Boolean; override val rating: IRating; @@ -34,6 +36,7 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { content = obj.getOrDefault(config, "content", contextName, "") ?: ""; _hasGetComments = _content.has("getComments"); + _hasGetContentRecommendations = _content.has("getContentRecommendations"); } override fun getComments(client: IPlatformClient): IPager? { @@ -51,9 +54,27 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { } override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? { + if(!_hasGetContentRecommendations || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") { + return@handleDevCall getContentRecommendationsJS(client); + } + else if(client is JSClient) + return getContentRecommendationsJS(client); + + return null; + } + private fun getContentRecommendationsJS(client: JSClient): JSContentPager { + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return JSContentPager(_pluginConfig, client, contentPager); + } private fun getCommentsJS(client: JSClient): JSCommentPager { val commentPager = _content.invoke("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } + } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt index 83137f23..da495498 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt @@ -6,6 +6,7 @@ import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -27,6 +28,7 @@ import com.futo.platformplayer.states.StateDeveloper class JSVideoDetails : JSVideo, IPlatformVideoDetails { private val _hasGetComments: Boolean; + private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; //Details @@ -66,6 +68,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { _hasGetComments = _content.has("getComments"); _hasGetPlaybackTracker = _content.has("getPlaybackTracker"); + _hasGetContentRecommendations = _content.has("getContentRecommendations"); } override fun getPlaybackTracker(): IPlaybackTracker? { @@ -89,6 +92,24 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { }; } + override fun getContentRecommendations(client: IPlatformClient): IPager? { + if(!_hasGetContentRecommendations || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getContentRecommendations()") { + return@handleDevCall getContentRecommendationsJS(client); + } + else if(client is JSClient) + return getContentRecommendationsJS(client); + + return null; + } + private fun getContentRecommendationsJS(client: JSClient): JSContentPager { + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return JSContentPager(_pluginConfig, client, contentPager); + } + override fun getComments(client: IPlatformClient): IPager? { if(client !is JSClient || !_hasGetComments || _content.isClosed) return null; diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt index 3b985796..7308b1c6 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor @@ -81,6 +82,8 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override fun getComments(client: IPlatformClient): IPager? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + fun toPlatformVideo() : IPlatformVideoDetails { throw NotImplementedError(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt index 20cdcaa2..5139c0f8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -144,10 +145,8 @@ class TutorialFragment : MainFragment() { override fun getComments(client: IPlatformClient): IPager { return EmptyPager() } - - override fun getPlaybackTracker(): IPlaybackTracker? { - return null - } + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; } companion object { 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 dbfa8886..3e14ff2f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -647,6 +647,15 @@ class StatePlatform { return client.getPlaybackTracker(url); } + fun getContentRecommendations(url: String): IPager? { + val baseClient = getContentClientOrNull(url) ?: return null; + if (baseClient !is JSClient) { + return baseClient.getContentRecommendations(url); + } + val client = _mainClientPool.getClientPooled(baseClient); + return client.getContentRecommendations(url); + } + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; fun getChannelClient(url : String, exclude: List? = null) : IPlatformClient = getChannelClientOrNull(url, exclude) ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); From 948b85ddcb094a6e4d2bdb2387b4e260e07d5b82 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 14 Jun 2024 08:43:18 +0200 Subject: [PATCH 17/28] Pushed updated submodules. --- app/src/stable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 843cf2dc..fdc95d7b 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 843cf2dc4b19c02d86a00407e2e8f9f1048cf475 +Subproject commit fdc95d7b70653e83fcf6a7c37fc0f8813dfb0a89 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 843cf2dc..fdc95d7b 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 843cf2dc4b19c02d86a00407e2e8f9f1048cf475 +Subproject commit fdc95d7b70653e83fcf6a7c37fc0f8813dfb0a89 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 2a38e0ce..c23302da 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 2a38e0cecc635df2122b142f2d0c9e13ac894772 +Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 From be2ae096ee11926eaccd8288304c8e1af2c3483f Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 14 Jun 2024 13:22:58 +0200 Subject: [PATCH 18/28] Fix locked content deserializer --- .../platformplayer/serializers/PlatformContentSerializer.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt index 70808ba2..02a39160 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.serializers import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformLockedContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformPost import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo @@ -30,6 +31,7 @@ class PlatformContentSerializer : JsonContentPolymorphicSerializer SerializedPlatformNestedContent.serializer(); "ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); "POST" -> SerializedPlatformPost.serializer(); + "LOCKED" -> SerializedPlatformLockedContent.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}") }; } else { @@ -38,6 +40,7 @@ class PlatformContentSerializer : JsonContentPolymorphicSerializer SerializedPlatformNestedContent.serializer(); ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented"); ContentType.POST.value -> SerializedPlatformPost.serializer(); + ContentType.LOCKED.value -> SerializedPlatformLockedContent.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj.jsonPrimitive.int}") }; } From 916936e1792289980e15bd8e41f8028636180f6a Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 14 Jun 2024 13:32:00 +0200 Subject: [PATCH 19/28] Implemented proper remote playlist support. --- .../platformplayer/activities/MainActivity.kt | 4 + .../mainactivity/main/ChannelFragment.kt | 2 +- .../mainactivity/main/ContentFeedView.kt | 14 +- .../main/ContentSearchResultsFragment.kt | 2 +- .../mainactivity/main/PlaylistFragment.kt | 53 --- .../main/RemotePlaylistFragment.kt | 362 ++++++++++++++++++ .../adapters/InsertedViewAdapterWithLoader.kt | 1 + .../adapters/VideoListEditorViewHolder.kt | 4 +- .../res/layout/fragment_remote_playlist.xml | 198 ++++++++++ 9 files changed, 578 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt create mode 100644 app/src/main/res/layout/fragment_remote_playlist.xml diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index f9565916..90902f5e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -104,6 +104,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainTutorial: TutorialFragment; lateinit var _fragMainPlaylists: PlaylistsFragment; lateinit var _fragMainPlaylist: PlaylistFragment; + lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; lateinit var _fragSourceDetail: SourceDetailFragment; @@ -246,6 +247,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSources = SourcesFragment.newInstance(); _fragMainPlaylists = PlaylistsFragment.newInstance(); _fragMainPlaylist = PlaylistFragment.newInstance(); + _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); @@ -331,6 +333,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSources.topBar = _fragTopBarAdd; _fragMainPlaylists.topBar = _fragTopBarGeneral; _fragMainPlaylist.topBar = _fragTopBarNavigation; + _fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation; @@ -1044,6 +1047,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { SourcesFragment::class -> _fragMainSources as T; PlaylistsFragment::class -> _fragMainPlaylists as T; PlaylistFragment::class -> _fragMainPlaylist as T; + RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; PostDetailFragment::class -> _fragPostDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; 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 dc419673..ad4aa84d 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 @@ -204,7 +204,7 @@ class ChannelFragment : MainFragment() { } is IPlatformPlaylist -> { - fragment.navigate(v) + fragment.navigate(v) } is IPlatformPost -> { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index cde70f32..fcabcb5b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -6,28 +6,32 @@ import android.view.LayoutInflater import android.widget.LinearLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.futo.platformplayer.* +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo -import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewHolder +import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.withTimestamp import kotlin.math.floor abstract class ContentFeedView : FeedView, ContentPreviewViewHolder> where TFragment : MainFragment { @@ -183,7 +187,7 @@ abstract class ContentFeedView : FeedView(content).maximizeVideoDetail(); } } else if (content is IPlatformPlaylist) { - fragment.navigate(content); + fragment.navigate(content); } else if (content is IPlatformPost) { fragment.navigate(content); } @@ -194,7 +198,7 @@ abstract class ContentFeedView : FeedView(url).maximizeVideoDetail(); }; - ContentType.PLAYLIST -> fragment.navigate(url); + ContentType.PLAYLIST -> fragment.navigate(url); ContentType.URL -> fragment.navigate(url); else -> {}; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index f7a34777..076c0542 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -156,7 +156,7 @@ class ContentSearchResultsFragment : MainFragment() { onSearch.subscribe(this) { if(it.isHttpUrl()) { if(StatePlatform.instance.hasEnabledPlaylistClient(it)) - navigate(it); + navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); else diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index 713668fe..7d8a788b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -1,14 +1,11 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.annotation.SuppressLint -import android.graphics.drawable.Animatable import android.os.Bundle -import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.app.ShareCompat -import androidx.core.view.setPadding import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist @@ -17,7 +14,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.downloads.VideoDownload -import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateApp @@ -30,7 +26,6 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class PlaylistFragment : MainFragment() { override val isMainView : Boolean = true; @@ -70,7 +65,6 @@ class PlaylistFragment : MainFragment() { private val _fragment: PlaylistFragment; private var _playlist: Playlist? = null; - private var _remotePlaylist: IPlatformPlaylistDetails? = null; private var _editPlaylistNameInput: SlideUpMenuTextInput? = null; private var _editPlaylistOverlay: SlideUpMenuOverlay? = null; private var _url: String? = null; @@ -136,7 +130,6 @@ class PlaylistFragment : MainFragment() { return@TaskHandler StatePlatform.instance.getPlaylist(it); }) .success { - _remotePlaylist = it; setName(it.name); //TODO: Implement support for pagination setVideos(it.toPlaylist().videos, false); @@ -155,7 +148,6 @@ class PlaylistFragment : MainFragment() { if (parameter is Playlist?) { _playlist = parameter; - _remotePlaylist = null; _url = null; if(parameter != null) { @@ -175,7 +167,6 @@ class PlaylistFragment : MainFragment() { //TODO: Do I have to remove the showConvertPlaylistButton(); button here? } else if (parameter is IPlatformPlaylist) { _playlist = null; - _remotePlaylist = null; _url = parameter.url; setVideoCount(parameter.videoCount); @@ -185,10 +176,8 @@ class PlaylistFragment : MainFragment() { setButtonEditVisible(false); fetchPlaylist(); - showConvertPlaylistButton(); } else if (parameter is String) { _playlist = null; - _remotePlaylist = null; _url = parameter; setName(null); @@ -198,7 +187,6 @@ class PlaylistFragment : MainFragment() { setButtonEditVisible(false); fetchPlaylist(); - showConvertPlaylistButton(); } _playlist?.let { @@ -242,34 +230,6 @@ class PlaylistFragment : MainFragment() { StateDownloads.instance.onDownloadedChanged.remove(this); } - private fun showConvertPlaylistButton() { - _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { - val remotePlaylist = _remotePlaylist; - if (remotePlaylist == null) { - UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)); - return@Pair; - } - - setLoading(true); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - StatePlaylists.instance.playlistStore.save(remotePlaylist.toPlaylist()); - - withContext(Dispatchers.Main) { - setLoading(false); - UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist)); - } - } catch (e: Throwable) { - withContext(Dispatchers.Main) { - setLoading(false); - } - - throw e; - } - } - })); - } - private fun fetchPlaylist() { Logger.i(TAG, "fetchPlaylist") @@ -290,21 +250,15 @@ class PlaylistFragment : MainFragment() { override fun onPlayAllClick() { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { StatePlayer.instance.setPlaylist(playlist, focus = true); - } else if (remotePlaylist != null) { - StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = false); } } override fun onShuffleClick() { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true); - } else if (remotePlaylist != null) { - StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = true); } } @@ -320,19 +274,12 @@ class PlaylistFragment : MainFragment() { } override fun onVideoClicked(video: IPlatformVideo) { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { val index = playlist.videos.indexOf(video); if (index == -1) return; StatePlayer.instance.setPlaylist(playlist, index, true); - } else if (remotePlaylist != null) { - val index = remotePlaylist.contents.getResults().indexOf(video); - if (index == -1) - return; - - StatePlayer.instance.setPlaylist(remotePlaylist, index, true); } } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt new file mode 100644 index 00000000..84e06047 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -0,0 +1,362 @@ +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.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.app.ShareCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +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.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.MultiPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.VideoListEditorViewHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RemotePlaylistFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: RemotePlaylistView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = RemotePlaylistView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class RemotePlaylistView : LinearLayout { + private val _fragment: RemotePlaylistFragment; + + private var _remotePlaylist: IPlatformPlaylistDetails? = null; + private var _url: String? = null; + private val _videos: ArrayList = arrayListOf(); + + private val _taskLoadPlaylist: TaskHandler; + private var _nextPageHandler: TaskHandler, List>; + + private var _imagePlaylistThumbnail: ImageView; + private var _textName: TextView; + private var _textMetadata: TextView; + private var _loaderOverlay: FrameLayout; + private var _imageLoader: ImageView; + private var _overlayContainer: FrameLayout; + private var _buttonShare: ImageButton; + private var _recyclerPlaylist: RecyclerView; + private var _llmPlaylist: LinearLayoutManager; + private val _adapterVideos: InsertedViewAdapterWithLoader; + private val _scrollListener: RecyclerView.OnScrollListener + + constructor(fragment: RemotePlaylistFragment, inflater: LayoutInflater) : super(inflater.context) { + inflater.inflate(R.layout.fragment_remote_playlist, this); + + _fragment = fragment; + + _textName = findViewById(R.id.text_name); + _textMetadata = findViewById(R.id.text_metadata); + _imagePlaylistThumbnail = findViewById(R.id.image_playlist_thumbnail); + _loaderOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + _recyclerPlaylist = findViewById(R.id.recycler_playlist); + _llmPlaylist = LinearLayoutManager(context); + _adapterVideos = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + childCountGetter = { _videos.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_videos[position], false); }, + childViewHolderFactory = { viewGroup, _ -> + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false); + val holder = VideoListEditorViewHolder(view, null); + holder.onClick.subscribe { + showConvertConfirmationModal() { + _fragment.navigate(it); + } + }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + _recyclerPlaylist.adapter = _adapterVideos; + _recyclerPlaylist.layoutManager = _llmPlaylist; + + _overlayContainer = findViewById(R.id.overlay_container); + val buttonPlayAll = findViewById(R.id.button_play_all); + val buttonShuffle = findViewById(R.id.button_shuffle); + + _buttonShare = findViewById(R.id.button_share); + _buttonShare.setOnClickListener { + val remotePlaylist = _remotePlaylist ?: return@setOnClickListener; + + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(remotePlaylist.shareUrl) + .intent); + }; + + buttonPlayAll.setOnClickListener { + showConvertConfirmationModal() { + _fragment.navigate(it); + } + }; + buttonShuffle.setOnClickListener { + showConvertConfirmationModal() { + _fragment.navigate(it); + } + }; + + _taskLoadPlaylist = TaskHandler( + StateApp.instance.scopeGetter, + { + return@TaskHandler StatePlatform.instance.getPlaylist(it); + }) + .success { + _remotePlaylist = it; + setName(it.name); + setVideos(it.contents.getResults()); + setVideoCount(it.videoCount); + setLoading(false); + } + .exception { + Logger.w(TAG, "Failed to load playlist.", it); + val c = context ?: return@exception; + UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist, null, fragment); + }; + + _nextPageHandler = TaskHandler, List>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) + it.nextPageAsync(); + else + it.nextPage(); + + processPagerExceptions(it); + return@TaskHandler it.getResults(); + }).success { + _adapterVideos.setLoading(false); + addVideos(it); + //TODO: ensureEnoughContentVisible() + }.exception { + Logger.w(TAG, "Failed to load next page.", it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { + loadNextPage(); + }, null, fragment); + }; + + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val visibleItemCount = _recyclerPlaylist.childCount + val firstVisibleItem = _llmPlaylist.findFirstVisibleItemPosition() + val visibleThreshold = 15 + if (!_adapterVideos.isLoading && firstVisibleItem + visibleItemCount + visibleThreshold >= _videos.size) { + loadNextPage() + } + } + } + + _recyclerPlaylist.addOnScrollListener(_scrollListener) + } + + private fun loadNextPage() { + val pager: IPager = _remotePlaylist?.contents ?: return; + val hasMorePages = pager.hasMorePages(); + Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages, page size=${pager.getResults().size}"); + + if (pager.hasMorePages()) { + _adapterVideos.setLoading(true); + _nextPageHandler.run(pager); + } + } + + private fun processPagerExceptions(pager: IPager<*>) { + if(pager is MultiPager<*> && pager.allowFailure) { + val ex = pager.getResultExceptions(); + for(kv in ex) { + val jsVideoPager: JSPager<*>? = if(kv.key is MultiPager<*>) + (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?; + else if(kv.key is JSPager<*>) + kv.key as JSPager<*>; + else null; + + context?.let { + _fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if(jsVideoPager != null) + UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", jsVideoPager.getPluginConfig().name).replace("{message}", kv.value.message ?: ""), false); + else + UIDialogs.toast(it, kv.value.message ?: "", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e) + } + } + } + } + } + } + + fun onShown(parameter: Any?) { + _taskLoadPlaylist.cancel(); + _nextPageHandler.cancel(); + + if (parameter is IPlatformPlaylist) { + _remotePlaylist = null; + _url = parameter.url; + + setVideoCount(parameter.videoCount); + setName(parameter.name); + setVideos(null); + + fetchPlaylist(); + showConvertPlaylistButton(); + } else if (parameter is String) { + _remotePlaylist = null; + _url = parameter; + + setName(null); + setVideos(null); + setVideoCount(-1); + + fetchPlaylist(); + showConvertPlaylistButton(); + } + } + + private fun showConvertConfirmationModal(onSuccess: ((playlist: Playlist) -> Unit)? = null) { + val remotePlaylist = _remotePlaylist; + if (remotePlaylist == null) { + UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)); + return; + } + + val c = context ?: return; + UIDialogs.showConfirmationDialog(c, "Conversion to local playlist is required for this action", { + setLoading(true); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val playlist = remotePlaylist.toPlaylist(); + StatePlaylists.instance.playlistStore.save(playlist); + + withContext(Dispatchers.Main) { + setLoading(false); + UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist)); + onSuccess?.invoke(playlist); + } + } catch (e: Throwable) { + withContext(Dispatchers.Main) { + setLoading(false); + } + + throw e; + } + } + }); + } + + private fun showConvertPlaylistButton() { + _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + showConvertConfirmationModal(); + })); + } + + private fun fetchPlaylist() { + Logger.i(TAG, "fetchPlaylist") + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPlaylist.run(url); + } + } + + private fun setName(name: String?) { + _textName.text = name ?: ""; + } + + private fun setVideoCount(videoCount: Int = -1) { + _textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos); + } + + private fun setVideos(videos: List?) { + if (!videos.isNullOrEmpty()) { + val video = videos.first(); + _imagePlaylistThumbnail.let { + Glide.with(it) + .load(video.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(it); + }; + } else { + _textMetadata.text = "0 " + context.getString(R.string.videos); + Glide.with(_imagePlaylistThumbnail) + .load(R.drawable.placeholder_video_thumbnail) + .into(_imagePlaylistThumbnail) + } + + synchronized(_videos) { + _videos.clear(); + _videos.addAll(videos ?: listOf()); + _adapterVideos.notifyDataSetChanged(); + } + } + + private fun addVideos(videos: List) { + synchronized(_videos) { + val index = _videos.size; + _videos.addAll(videos); + _adapterVideos.notifyItemRangeInserted(_adapterVideos.childToParentPosition(index), videos.size); + } + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading){ + (_imageLoader.drawable as Animatable?)?.start() + _loaderOverlay.visibility = View.VISIBLE; + } + else { + _loaderOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + } + + companion object { + private const val TAG = "RemotePlaylistFragment"; + fun newInstance() = RemotePlaylistFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt index de012903..19989056 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.R open class InsertedViewAdapterWithLoader : InsertedViewAdapter where TViewHolder : ViewHolder { private var _loaderView: ImageView? = null; private var _loading = false; + val isLoading get() = _loading; constructor( context: Context, diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 8f94f6d2..3cf3194b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -43,7 +43,7 @@ class VideoListEditorViewHolder : ViewHolder { val onRemove = Event1(); @SuppressLint("ClickableViewAccessibility") - constructor(view: View, touchHelper: ItemTouchHelper) : super(view) { + constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { _root = view.findViewById(R.id.root); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); _imageThumbnail?.clipToOutline = true; @@ -59,7 +59,7 @@ class VideoListEditorViewHolder : ViewHolder { _layoutDownloaded = view.findViewById(R.id.layout_downloaded); _imageDragDrop.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { + if (touchHelper != null && event.action == MotionEvent.ACTION_DOWN) { touchHelper.startDrag(this); } false diff --git a/app/src/main/res/layout/fragment_remote_playlist.xml b/app/src/main/res/layout/fragment_remote_playlist.xml new file mode 100644 index 00000000..bee1f714 --- /dev/null +++ b/app/src/main/res/layout/fragment_remote_playlist.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +W + + + + + + + + + + + + + + + + + \ No newline at end of file From 2a6ba6d5412119cb68815db5ec0ca5b8754c5621 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 14 Jun 2024 14:54:37 +0200 Subject: [PATCH 20/28] Fixed remote playlist ToPlaylist. --- .../playlists/IPlatformPlaylistDetails.kt | 2 +- .../platforms/js/models/JSPlaylistDetails.kt | 18 +++--- .../main/RemotePlaylistFragment.kt | 59 ++++++++++--------- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt index b7783668..28947655 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt @@ -8,5 +8,5 @@ interface IPlatformPlaylistDetails: IPlatformPlaylist { //TODO: Determine if this should be IPlatformContent (probably not?) val contents: IPager; - fun toPlaylist(): Playlist; + fun toPlaylist(onProgress: ((progress: Int) -> Unit)? = null): Playlist; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt index cbd4e013..f2c3935c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt @@ -7,7 +7,7 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.api.media.structures.ReusablePager import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.models.Playlist @@ -15,22 +15,26 @@ class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails { override val contents: IPager; constructor(plugin: JSClient, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { - contents = JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails")); + contents = ReusablePager(JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails"))); } - override fun toPlaylist(): Playlist { - val videos = contents.getResults().toMutableList(); + override fun toPlaylist(onProgress: ((progress: Int) -> Unit)?): Playlist { + val playlist = if (contents is ReusablePager) contents.getWindow() else contents; + val videos = playlist.getResults().toMutableList(); + onProgress?.invoke(videos.size); //Download all pages var allowedEmptyCount = 2; - while(contents.hasMorePages()) { - contents.nextPage(); - if(!videos.addAll(contents.getResults())) { + while(playlist.hasMorePages()) { + playlist.nextPage(); + if(!videos.addAll(playlist.getResults())) { allowedEmptyCount--; if(allowedEmptyCount <= 0) break; } else allowedEmptyCount = 2; + + onProgress?.invoke(videos.size); } return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)}); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt index 84e06047..188aa5dd 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -24,11 +24,11 @@ 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.MultiPager +import com.futo.platformplayer.api.media.structures.ReusablePager import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlaylists @@ -66,6 +66,7 @@ class RemotePlaylistFragment : MainFragment() { private val _fragment: RemotePlaylistFragment; private var _remotePlaylist: IPlatformPlaylistDetails? = null; + private var _remotePlaylistPagerWindow: IPager? = null; private var _url: String? = null; private val _videos: ArrayList = arrayListOf(); @@ -103,9 +104,7 @@ class RemotePlaylistFragment : MainFragment() { val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false); val holder = VideoListEditorViewHolder(view, null); holder.onClick.subscribe { - showConvertConfirmationModal() { - _fragment.navigate(it); - } + showConvertConfirmationModal(); }; return@InsertedViewAdapterWithLoader holder; } @@ -129,14 +128,10 @@ class RemotePlaylistFragment : MainFragment() { }; buttonPlayAll.setOnClickListener { - showConvertConfirmationModal() { - _fragment.navigate(it); - } + showConvertConfirmationModal(); }; buttonShuffle.setOnClickListener { - showConvertConfirmationModal() { - _fragment.navigate(it); - } + showConvertConfirmationModal(); }; _taskLoadPlaylist = TaskHandler( @@ -146,8 +141,10 @@ class RemotePlaylistFragment : MainFragment() { }) .success { _remotePlaylist = it; + val c = it.contents; + _remotePlaylistPagerWindow = if (c is ReusablePager) c.getWindow() else c; setName(it.name); - setVideos(it.contents.getResults()); + setVideos(_remotePlaylistPagerWindow!!.getResults()); setVideoCount(it.videoCount); setLoading(false); } @@ -193,7 +190,7 @@ class RemotePlaylistFragment : MainFragment() { } private fun loadNextPage() { - val pager: IPager = _remotePlaylist?.contents ?: return; + val pager: IPager = _remotePlaylistPagerWindow ?: return; val hasMorePages = pager.hasMorePages(); Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages, page size=${pager.getResults().size}"); @@ -256,7 +253,7 @@ class RemotePlaylistFragment : MainFragment() { } } - private fun showConvertConfirmationModal(onSuccess: ((playlist: Playlist) -> Unit)? = null) { + private fun showConvertConfirmationModal() { val remotePlaylist = _remotePlaylist; if (remotePlaylist == null) { UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)); @@ -266,22 +263,30 @@ class RemotePlaylistFragment : MainFragment() { val c = context ?: return; UIDialogs.showConfirmationDialog(c, "Conversion to local playlist is required for this action", { setLoading(true); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - val playlist = remotePlaylist.toPlaylist(); - StatePlaylists.instance.playlistStore.save(playlist); - withContext(Dispatchers.Main) { - setLoading(false); - UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist)); - onSuccess?.invoke(playlist); - } - } catch (e: Throwable) { - withContext(Dispatchers.Main) { - setLoading(false); - } + UIDialogs.showDialogProgress(context) { + it.setText("Converting playlist.."); + it.setProgress(0f); - throw e; + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val playlist = remotePlaylist.toPlaylist() { progress -> + _fragment.lifecycleScope.launch(Dispatchers.Main) { + it.setProgress(progress.toDouble() / remotePlaylist.videoCount); + } + }; + + StatePlaylists.instance.playlistStore.save(playlist); + + withContext(Dispatchers.Main) { + UIDialogs.toast("Playlist converted"); + it.dismiss(); + _fragment.navigate(playlist); + } + } + catch(ex: Throwable) { + UIDialogs.appToast("Failed to convert playlist.\n" + ex.message); + } } } }); From 74b992664722fafb9d447ed8170c0855f037dc59 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 19 Jun 2024 19:14:05 +0200 Subject: [PATCH 21/28] Refs --- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index fdc95d7b..4e826dcb 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit fdc95d7b70653e83fcf6a7c37fc0f8813dfb0a89 +Subproject commit 4e826dcb6a237313e32ec81b0e973a4f69c429c3 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index c23302da..5032e4e1 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 +Subproject commit 5032e4e10a353324aeac582d58fef35c1743e967 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index fdc95d7b..4e826dcb 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit fdc95d7b70653e83fcf6a7c37fc0f8813dfb0a89 +Subproject commit 4e826dcb6a237313e32ec81b0e973a4f69c429c3 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index c23302da..5032e4e1 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit c23302da76fc706faf02f7d9331ed28baed04607 +Subproject commit 5032e4e10a353324aeac582d58fef35c1743e967 From c275415a4944841d7b237e38b5540e1d33b8de8f Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 20 Jun 2024 11:51:11 +0200 Subject: [PATCH 22/28] Hide playlist video count if unknown --- app/src/main/assets/scripts/source.js | 2 +- .../api/media/platforms/js/models/JSPlaylist.kt | 2 +- .../platformplayer/views/adapters/PlaylistView.kt | 12 +++++++++++- .../viewholders/ImportPlaylistsViewHolder.kt | 8 +++++++- app/src/main/res/layout/list_playlist_feed.xml | 1 + .../main/res/layout/list_playlist_feed_preview.xml | 8 ++++---- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 7e619b3e..0324e0f5 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -436,7 +436,7 @@ class PlatformPlaylist extends PlatformContent { constructor(obj) { super(obj, 4); this.plugin_type = "PlatformPlaylist"; - this.videoCount = obj.videoCount ?? 0; + this.videoCount = obj.videoCount ?? -1; this.thumbnail = obj.thumbnail; } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt index b47ae9ea..787b53e4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt @@ -14,6 +14,6 @@ open class JSPlaylist : JSContent, IPlatformPlaylist { constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { val contextName = "Playlist"; thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null); - videoCount = obj.getOrDefault(config, "videoCount", contextName, 0)!!; + videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index a35d8147..b606bf26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -33,6 +33,7 @@ open class PlaylistView : LinearLayout { protected val _platformIndicator: PlatformIndicator; protected val _textPlaylistName: TextView protected val _textVideoCount: TextView + protected val _textVideoCountLabel: TextView; protected val _textPlaylistItems: TextView protected val _textChannelName: TextView protected var _neopassAnimator: ObjectAnimator? = null; @@ -62,6 +63,7 @@ open class PlaylistView : LinearLayout { _platformIndicator = findViewById(R.id.thumbnail_platform); _textPlaylistName = findViewById(R.id.text_playlist_name); _textVideoCount = findViewById(R.id.text_video_count); + _textVideoCountLabel = findViewById(R.id.text_video_count_label); _textChannelName = findViewById(R.id.text_channel_name); _textPlaylistItems = findViewById(R.id.text_playlist_items); _imageNeopassChannel = findViewById(R.id.image_neopass_channel); @@ -137,7 +139,15 @@ open class PlaylistView : LinearLayout { .crossfade() .into(_imageThumbnail); - _textVideoCount.text = content.videoCount.toString(); + if(content.videoCount >= 0) { + _textVideoCount.text = content.videoCount.toString(); + _textVideoCount.visibility = View.VISIBLE; + _textVideoCountLabel.visibility = VISIBLE; + } + else { + _textVideoCount.visibility = View.GONE; + _textVideoCountLabel.visibility = GONE; + } } else { currentPlaylist = null; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt index e17e84aa..66ab0193 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.views.adapters.viewholders import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout @@ -46,7 +47,12 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. override fun bind(value: SelectablePlaylist) { _textName.text = value.playlist.name; - _textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos); + if(value.playlist.videoCount >= 0) { + _textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos); + _textMetadata.visibility = View.VISIBLE; + } + else + _textMetadata.visibility = View.GONE; _checkbox.value = value.selected; val thumbnail = value.playlist.thumbnail; diff --git a/app/src/main/res/layout/list_playlist_feed.xml b/app/src/main/res/layout/list_playlist_feed.xml index a9eb1ddc..f2f30845 100644 --- a/app/src/main/res/layout/list_playlist_feed.xml +++ b/app/src/main/res/layout/list_playlist_feed.xml @@ -68,6 +68,7 @@ android:textColor="@color/gray_7f"/> + app:layout_constraintRight_toLeftOf="@id/text_video_count_label" + app:layout_constraintBottom_toBottomOf="@id/text_video_count_label" /> + app:layout_constraintBottom_toTopOf="@id/text_video_count_label"/> Date: Mon, 24 Jun 2024 10:49:43 -0500 Subject: [PATCH 23/28] don't save playlists that weren't explicitly copied fixed exception failed to convert playlist job cancelled --- .../main/RemotePlaylistFragment.kt | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt index 188aa5dd..164434da 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -104,7 +104,7 @@ class RemotePlaylistFragment : MainFragment() { val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false); val holder = VideoListEditorViewHolder(view, null); holder.onClick.subscribe { - showConvertConfirmationModal(); + showConvertConfirmationModal(false); }; return@InsertedViewAdapterWithLoader holder; } @@ -128,10 +128,10 @@ class RemotePlaylistFragment : MainFragment() { }; buttonPlayAll.setOnClickListener { - showConvertConfirmationModal(); + showConvertConfirmationModal(false); }; buttonShuffle.setOnClickListener { - showConvertConfirmationModal(); + showConvertConfirmationModal(false); }; _taskLoadPlaylist = TaskHandler( @@ -253,48 +253,52 @@ class RemotePlaylistFragment : MainFragment() { } } - private fun showConvertConfirmationModal() { - val remotePlaylist = _remotePlaylist; + private fun showConvertConfirmationModal(savePlaylist: Boolean) { + val remotePlaylist = _remotePlaylist if (remotePlaylist == null) { - UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)); - return; + UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)) + return } - val c = context ?: return; - UIDialogs.showConfirmationDialog(c, "Conversion to local playlist is required for this action", { - setLoading(true); + val c = context ?: return + UIDialogs.showConfirmationDialog( + c, + "Conversion to local playlist is required for this action", + { + setLoading(true) - UIDialogs.showDialogProgress(context) { - it.setText("Converting playlist.."); - it.setProgress(0f); + UIDialogs.showDialogProgress(context) { + it.setText("Converting playlist..") + it.setProgress(0f) - _fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - val playlist = remotePlaylist.toPlaylist() { progress -> - _fragment.lifecycleScope.launch(Dispatchers.Main) { - it.setProgress(progress.toDouble() / remotePlaylist.videoCount); + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val playlist = remotePlaylist.toPlaylist { progress -> + _fragment.lifecycleScope.launch(Dispatchers.Main) { + it.setProgress(progress.toDouble() / remotePlaylist.videoCount) + } } - }; - StatePlaylists.instance.playlistStore.save(playlist); + if (savePlaylist) { + StatePlaylists.instance.playlistStore.save(playlist) + } - withContext(Dispatchers.Main) { - UIDialogs.toast("Playlist converted"); - it.dismiss(); - _fragment.navigate(playlist); + _fragment.lifecycleScope.launch(Dispatchers.Main) { + UIDialogs.toast("Playlist converted") + it.dismiss() + _fragment.navigate(playlist) + } + } catch (ex: Throwable) { + UIDialogs.appToast("Failed to convert playlist.\n" + ex.message) } } - catch(ex: Throwable) { - UIDialogs.appToast("Failed to convert playlist.\n" + ex.message); - } } - } - }); + }) } private fun showConvertPlaylistButton() { _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { - showConvertConfirmationModal(); + showConvertConfirmationModal(true); })); } From 696e03941a6d8474e49f111c6358aa305682d9bf Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Mon, 24 Jun 2024 13:00:58 -0500 Subject: [PATCH 24/28] pass through actions to local playlist and auto convert playlists with 20 or fewer videos --- .../main/RemotePlaylistFragment.kt | 119 ++++++++++++------ 1 file changed, 78 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt index 164434da..f76a6770 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -31,12 +31,16 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.VideoListEditorViewHolder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext + +enum class Action { + PLAY_ALL, SHUFFLE, PLAY, NONE +} class RemotePlaylistFragment : MainFragment() { override val isMainView : Boolean = true; @@ -85,6 +89,8 @@ class RemotePlaylistFragment : MainFragment() { private val _adapterVideos: InsertedViewAdapterWithLoader; private val _scrollListener: RecyclerView.OnScrollListener + + constructor(fragment: RemotePlaylistFragment, inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_remote_playlist, this); @@ -97,18 +103,25 @@ class RemotePlaylistFragment : MainFragment() { _imageLoader = findViewById(R.id.image_loader); _recyclerPlaylist = findViewById(R.id.recycler_playlist); _llmPlaylist = LinearLayoutManager(context); - _adapterVideos = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + _adapterVideos = InsertedViewAdapterWithLoader(context, + arrayListOf(), + arrayListOf(), childCountGetter = { _videos.size }, - childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_videos[position], false); }, + childViewHolderBinder = { viewHolder, position -> + viewHolder.bind( + _videos[position], + false + ) + }, childViewHolderFactory = { viewGroup, _ -> - val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false); - val holder = VideoListEditorViewHolder(view, null); + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.list_playlist, viewGroup, false) + val holder = VideoListEditorViewHolder(view, null) holder.onClick.subscribe { - showConvertConfirmationModal(false); - }; - return@InsertedViewAdapterWithLoader holder; - } - ); + convertPlaylist(false, Action.PLAY, holder.video) + } + return@InsertedViewAdapterWithLoader holder + }) _recyclerPlaylist.adapter = _adapterVideos; _recyclerPlaylist.layoutManager = _llmPlaylist; @@ -128,10 +141,10 @@ class RemotePlaylistFragment : MainFragment() { }; buttonPlayAll.setOnClickListener { - showConvertConfirmationModal(false); + convertPlaylist(false, Action.PLAY_ALL); }; buttonShuffle.setOnClickListener { - showConvertConfirmationModal(false); + convertPlaylist(false, Action.SHUFFLE); }; _taskLoadPlaylist = TaskHandler( @@ -253,52 +266,76 @@ class RemotePlaylistFragment : MainFragment() { } } - private fun showConvertConfirmationModal(savePlaylist: Boolean) { + private fun convertPlaylist( + savePlaylist: Boolean, action: Action, video: IPlatformVideo? = null + ) { val remotePlaylist = _remotePlaylist if (remotePlaylist == null) { UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)) return } - val c = context ?: return - UIDialogs.showConfirmationDialog( - c, - "Conversion to local playlist is required for this action", - { - setLoading(true) + val convert = { + setLoading(true) - UIDialogs.showDialogProgress(context) { - it.setText("Converting playlist..") - it.setProgress(0f) - - _fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - val playlist = remotePlaylist.toPlaylist { progress -> - _fragment.lifecycleScope.launch(Dispatchers.Main) { - it.setProgress(progress.toDouble() / remotePlaylist.videoCount) - } - } - - if (savePlaylist) { - StatePlaylists.instance.playlistStore.save(playlist) - } + UIDialogs.showDialogProgress(context) { + it.setText("Converting playlist..") + it.setProgress(0f) + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val playlist = remotePlaylist.toPlaylist { progress -> _fragment.lifecycleScope.launch(Dispatchers.Main) { - UIDialogs.toast("Playlist converted") - it.dismiss() - _fragment.navigate(playlist) + it.setProgress(progress.toDouble() / remotePlaylist.videoCount) } - } catch (ex: Throwable) { - UIDialogs.appToast("Failed to convert playlist.\n" + ex.message) } + + if (savePlaylist) { + StatePlaylists.instance.playlistStore.save(playlist) + } + + _fragment.lifecycleScope.launch(Dispatchers.Main) { + UIDialogs.toast("Playlist converted") + it.dismiss() + _fragment.navigate(playlist) + when (action) { + Action.SHUFFLE -> StatePlayer.instance.setPlaylist( + playlist, focus = true, shuffle = true + ) + + Action.PLAY_ALL -> StatePlayer.instance.setPlaylist( + playlist, focus = true + ) + + Action.PLAY -> { + StatePlayer.instance.setPlaylist( + playlist, _videos.indexOf(video), true + ) + } + + Action.NONE -> {} + } + } + } catch (ex: Throwable) { + UIDialogs.appToast("Failed to convert playlist.\n" + ex.message) } } - }) + } + } + + if (remotePlaylist.videoCount > 20) { + val c = context ?: return + UIDialogs.showConfirmationDialog( + c, "Conversion to local playlist is required for this action", convert + ) + } else { + convert() + } } private fun showConvertPlaylistButton() { _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { - showConvertConfirmationModal(true); + convertPlaylist(true, Action.NONE); })); } From b7781264d3d168aa7dff594879d43f7003911f3a Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Tue, 25 Jun 2024 10:22:23 -0500 Subject: [PATCH 25/28] changed playlist limit to 100 added save button to non-saved local playlists --- .../mainactivity/main/PlaylistFragment.kt | 73 ++++++++++--------- .../main/RemotePlaylistFragment.kt | 2 +- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index 7d8a788b..207c635e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateApp @@ -144,53 +145,59 @@ class PlaylistFragment : MainFragment() { } fun onShown(parameter: Any?) { - _taskLoadPlaylist.cancel(); + _taskLoadPlaylist.cancel() if (parameter is Playlist?) { - _playlist = parameter; - _url = null; + _playlist = parameter + _url = null - if(parameter != null) { - setName(parameter.name); - setVideos(parameter.videos, true); - setVideoCount(parameter.videos.size); - setButtonDownloadVisible(true); - setButtonEditVisible(true); + if (parameter != null) { + setName(parameter.name) + setVideos(parameter.videos, true) + setVideoCount(parameter.videos.size) + setButtonDownloadVisible(true) + setButtonEditVisible(true) + + if (!StatePlaylists.instance.playlistStore.getItems().contains(parameter)) { + _fragment.topBar?.assume() + ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + StatePlaylists.instance.playlistStore.save(parameter) + UIDialogs.toast("Playlist saved") + })) + } } else { - setName(null); - setVideos(null, false); - setVideoCount(-1); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setName(null) + setVideos(null, false) + setVideoCount(-1) + setButtonDownloadVisible(false) + setButtonEditVisible(false) } - - //TODO: Do I have to remove the showConvertPlaylistButton(); button here? } else if (parameter is IPlatformPlaylist) { - _playlist = null; - _url = parameter.url; + _playlist = null + _url = parameter.url - setVideoCount(parameter.videoCount); - setName(parameter.name); - setVideos(null, false); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setVideoCount(parameter.videoCount) + setName(parameter.name) + setVideos(null, false) + setButtonDownloadVisible(false) + setButtonEditVisible(false) - fetchPlaylist(); + fetchPlaylist() } else if (parameter is String) { - _playlist = null; - _url = parameter; + _playlist = null + _url = parameter - setName(null); - setVideos(null, false); - setVideoCount(-1); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setName(null) + setVideos(null, false) + setVideoCount(-1) + setButtonDownloadVisible(false) + setButtonEditVisible(false) - fetchPlaylist(); + fetchPlaylist() } _playlist?.let { - updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download); + updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download) } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt index f76a6770..349b4fb0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -323,7 +323,7 @@ class RemotePlaylistFragment : MainFragment() { } } - if (remotePlaylist.videoCount > 20) { + if (remotePlaylist.videoCount > 100) { val c = context ?: return UIDialogs.showConfirmationDialog( c, "Conversion to local playlist is required for this action", convert From bc550ae8f512350407708a307fd461154f53b7fc Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 26 Jun 2024 16:01:08 +0200 Subject: [PATCH 26/28] Removed exporting service. --- app/src/main/AndroidManifest.xml | 3 - .../platformplayer/downloads/VideoExport.kt | 43 ++-- .../mainactivity/main/DownloadsFragment.kt | 16 +- .../services/ExportingService.kt | 236 ------------------ .../futo/platformplayer/states/StateApp.kt | 3 - .../platformplayer/states/StateDownloads.kt | 106 +++----- .../platformplayer/views/MonetizationView.kt | 2 +- .../viewholders/VideoDownloadViewHolder.kt | 10 +- 8 files changed, 62 insertions(+), 357 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/services/ExportingService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 49076988..be7cd437 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,9 +41,6 @@ - diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 81f0fb09..7c1c4e09 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -1,47 +1,37 @@ package com.futo.platformplayer.downloads import android.content.Context -import android.net.Uri -import android.os.Environment import androidx.documentfile.provider.DocumentFile -import com.arthenica.ffmpegkit.* -import com.futo.platformplayer.api.media.models.streams.sources.* -import com.futo.platformplayer.constructs.Event1 +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.LogCallback +import com.arthenica.ffmpegkit.ReturnCode +import com.arthenica.ffmpegkit.StatisticsCallback +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.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanBitrate -import kotlinx.coroutines.* -import java.io.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.OutputStream import java.util.UUID -import java.util.concurrent.CancellationException import java.util.concurrent.Executors import kotlin.coroutines.resumeWithException @kotlinx.serialization.Serializable class VideoExport { - var state: State = State.QUEUED; - var videoLocal: VideoLocal; var videoSource: LocalVideoSource?; var audioSource: LocalAudioSource?; var subtitleSource: LocalSubtitleSource?; - var progress: Double = 0.0; - var isCancelled = false; - - var error: String? = null; - - @kotlinx.serialization.Transient - val onStateChanged = Event1(); - @kotlinx.serialization.Transient - val onProgressChanged = Event1(); - - fun changeState(newState: State) { - state = newState; - onStateChanged.emit(newState); - } - constructor(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { this.videoLocal = videoLocal; this.videoSource = videoSource; @@ -50,8 +40,6 @@ class VideoExport { } suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope { - if(isCancelled) throw CancellationException("Export got cancelled"); - val v = videoSource; val a = audioSource; val s = subtitleSource; @@ -107,7 +95,6 @@ class VideoExport { throw Exception("Cannot export when no audio or video source is set."); } - onProgressChanged.emit(100.0); return@coroutineScope outputFile; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index ae584d40..93b5e7e9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -8,7 +8,7 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import com.futo.platformplayer.* +import com.futo.platformplayer.R import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.logging.Logger @@ -16,12 +16,13 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop -import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder import com.futo.platformplayer.views.items.ActiveDownloadItem import com.futo.platformplayer.views.items.PlaylistDownloadItem +import com.futo.platformplayer.views.others.ProgressBar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -64,16 +65,6 @@ class DownloadsFragment : MainFragment() { } } }; - StateDownloads.instance.onExportsChanged.subscribe(this) { - lifecycleScope.launch(Dispatchers.Main) { - try { - Logger.i(TAG, "Reloading UI for exports"); - _view?.reloadUI() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to reload UI for exports", e) - } - } - }; } override fun onPause() { @@ -81,7 +72,6 @@ class DownloadsFragment : MainFragment() { StateDownloads.instance.onDownloadsChanged.remove(this); StateDownloads.instance.onDownloadedChanged.remove(this); - StateDownloads.instance.onExportsChanged.remove(this); } private class DownloadsView : LinearLayout { diff --git a/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt b/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt deleted file mode 100644 index a447f70c..00000000 --- a/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.futo.platformplayer.services - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import com.futo.platformplayer.R -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.downloads.VideoExport -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.share -import com.futo.platformplayer.states.Announcement -import com.futo.platformplayer.states.AnnouncementType -import com.futo.platformplayer.states.StateAnnouncement -import com.futo.platformplayer.states.StateDownloads -import com.futo.platformplayer.stores.FragmentedStorage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.time.OffsetDateTime -import java.util.UUID - - -class ExportingService : Service() { - private val TAG = "ExportingService"; - - private val EXPORT_NOTIF_ID = 4; - private val EXPORT_NOTIF_TAG = "export"; - private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel"; - private val EXPORT_NOTIF_CHANNEL_NAME = "Export"; - - //Context - private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); - private var _notificationManager: NotificationManager? = null; - private var _notificationChannel: NotificationChannel? = null; - - private val _client = ManagedHttpClient(); - - private var _started = false; - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Logger.i(TAG, "onStartCommand"); - - synchronized(this) { - if(_started) - return START_STICKY; - - if(!FragmentedStorage.isInitialized) { - closeExportSession(); - return START_NOT_STICKY; - } - - _started = true; - } - setupNotificationRequirements(); - - _callOnStarted?.invoke(this); - _instance = this; - - _scope.launch { - try { - doExporting(); - } - catch(ex: Throwable) { - try { - StateAnnouncement.instance.registerAnnouncementSession( - Announcement( - "rootExportException", - "An root export service exception happened", - ex.message ?: "", - AnnouncementType.SESSION, - OffsetDateTime.now() - ) - ); - } catch(_: Throwable){} - } - }; - - return START_STICKY; - } - fun setupNotificationRequirements() { - _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; - _notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply { - this.enableVibration(false); - this.setSound(null, null); - }; - _notificationManager!!.createNotificationChannel(_notificationChannel!!); - } - - override fun onCreate() { - Logger.i(TAG, "onCreate"); - super.onCreate() - } - - override fun onBind(p0: Intent?): IBinder? { - return null; - } - - private suspend fun doExporting() { - Logger.i(TAG, "doExporting - Starting Exports"); - val ignore = mutableListOf(); - var currentExport: VideoExport? = StateDownloads.instance.getExporting().firstOrNull(); - while (currentExport != null) - { - try{ - notifyExport(currentExport); - doExport(applicationContext, currentExport); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex); - currentExport.error = ex.message; - currentExport.changeState(VideoExport.State.ERROR); - ignore.add(currentExport); - - //Give it a sec - Thread.sleep(500); - } - - currentExport = StateDownloads.instance.getExporting().filter { !ignore.contains(it) }.firstOrNull(); - } - Logger.i(TAG, "doExporting - Ending Exports"); - stopService(this); - } - - private suspend fun doExport(context: Context, export: VideoExport) { - Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); - - export.changeState(VideoExport.State.EXPORTING); - - var lastNotifyTime: Long = 0L; - val file = export.export(context) { progress -> - export.progress = progress; - - val currentTime = System.currentTimeMillis(); - if (currentTime - lastNotifyTime > 500) { - notifyExport(export); - lastNotifyTime = currentTime; - } - } - export.changeState(VideoExport.State.COMPLETED); - Logger.i(TAG, "Export [${export.videoLocal.name}] finished"); - StateDownloads.instance.removeExport(export); - notifyExport(export); - - withContext(Dispatchers.Main) { - StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { - file.share(this@ExportingService); - }; - } - } - - private fun notifyExport(export: VideoExport) { - val channel = _notificationChannel ?: return; - - val bringUpIntent = Intent(this, MainActivity::class.java); - bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - bringUpIntent.action = "TAB"; - bringUpIntent.putExtra("TAB", "Exports"); - - var builder = NotificationCompat.Builder(this, EXPORT_NOTIF_TAG) - .setSmallIcon(R.drawable.ic_export) - .setOngoing(true) - .setSilent(true) - .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) - .setContentTitle("${export.state}: ${export.videoLocal.name}") - .setContentText(export.getExportInfo()) - .setProgress(100, (export.progress * 100).toInt(), export.progress == 0.0) - .setChannelId(channel.id) - - val notif = builder.build(); - notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(EXPORT_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); - } else { - startForeground(EXPORT_NOTIF_ID, notif); - } - } - - fun closeExportSession() { - Logger.i(TAG, "closeExportSession"); - stopForeground(STOP_FOREGROUND_REMOVE); - _notificationManager?.cancel(EXPORT_NOTIF_ID); - stopService(); - _started = false; - super.stopSelf(); - } - override fun onDestroy() { - Logger.i(TAG, "onDestroy"); - _instance = null; - _scope.cancel("onDestroy"); - super.onDestroy(); - } - - companion object { - private var _instance: ExportingService? = null; - private var _callOnStarted: ((ExportingService)->Unit)? = null; - - @Synchronized - fun getOrCreateService(context: Context, handle: ((ExportingService)->Unit)? = null) { - if(!FragmentedStorage.isInitialized) - return; - if(_instance == null) { - _callOnStarted = handle; - val intent = Intent(context, ExportingService::class.java); - context.startForegroundService(intent); - } - else _instance?.let { - if(handle != null) - handle(it); - } - } - @Synchronized - fun getService() : ExportingService? { - return _instance; - } - - @Synchronized - fun stopService(service: ExportingService? = null) { - (service ?: _instance)?.let { - if(_instance == it) - _instance = null; - it.closeExportSession(); - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 7fd26d63..ae0f24a3 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -445,9 +445,6 @@ class StateApp { DownloadService.getOrCreateService(context); } - Logger.i(TAG, "MainApp Started: Check [Exports]"); - StateDownloads.instance.checkForExportTodos(); - Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]"); val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index edeb1859..c07f74b9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -1,13 +1,13 @@ package com.futo.platformplayer.states import android.content.ContentResolver +import android.content.Context import android.os.StatFs import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource @@ -27,10 +27,14 @@ import com.futo.platformplayer.models.DiskUsage import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.PlaylistDownloaded import com.futo.platformplayer.services.DownloadService -import com.futo.platformplayer.services.ExportingService +import com.futo.platformplayer.share import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File +import java.util.UUID /*** * Used to maintain downloads @@ -50,12 +54,8 @@ class StateDownloads { private val _downloadPlaylists = FragmentedStorage.storeJson("playlistDownloads") .load(); - private val _exporting = FragmentedStorage.storeJson("exporting") - .load(); - private lateinit var _downloadedSet: HashSet; - val onExportsChanged = Event0(); val onDownloadsChanged = Event0(); val onDownloadedChanged = Event0(); @@ -457,17 +457,6 @@ class StateDownloads { } } - try { - val currentDownloads = _downloaded.getItems().map { it.url }.toHashSet(); - val exporting = _exporting.findItems { !currentDownloads.contains(it.videoLocal.url) }; - for (export in exporting) - _exporting.delete(export); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed to delete dangling export:", ex); - UIDialogs.toast("Failed to delete dangling export:\n" + ex); - } - return Pair(totalDeletedCount, totalDeleted); } @@ -475,66 +464,41 @@ class StateDownloads { return _downloadsDirectory; } + fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { + var lastNotifyTime = -1L; + UIDialogs.showDialogProgress(context) { + it.setText("Exporting content.."); + it.setProgress(0f); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val export = VideoExport(videoLocal, videoSource, audioSource, subtitleSource); + try { + Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); - //Export - fun getExporting(): List { - return _exporting.getItems(); - } - fun checkForExportTodos() { - if(_exporting.hasItems()) { - StateApp.withContext { - ExportingService.getOrCreateService(it); + val file = export.export(context) { progress -> + val now = System.currentTimeMillis(); + if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { + it.setProgress(progress); + lastNotifyTime = now; + } + } + + withContext(Dispatchers.Main) { + it.setProgress(100.0f) + it.dismiss() + + StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { + file.share(context); + }; + } + } catch(ex: Throwable) { + Logger.e(TAG, "Failed export [${export.videoLocal.name}]: ${ex.message}", ex); + + } } } } - fun validateExport(export: VideoExport) { - if(_exporting.hasItem { it.videoLocal.url == export.videoLocal.url }) - throw AlreadyQueuedException("Video [${export.videoLocal.name}] is already queued for export"); - } - fun export(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, notify: Boolean = true) { - val shortName = if(videoLocal.name.length > 23) - videoLocal.name.substring(0, 20) + "..."; - else - videoLocal.name; - - val videoExport = VideoExport(videoLocal, videoSource, audioSource, subtitleSource); - - try { - validateExport(videoExport); - _exporting.save(videoExport); - - if(notify) { - UIDialogs.toast("Exporting [${shortName}]"); - StateApp.withContext { ExportingService.getOrCreateService(it) }; - onExportsChanged.emit(); - } - } - catch (ex: AlreadyQueuedException) { - Logger.e(TAG, "File is already queued for export.", ex); - StateApp.withContext { ExportingService.getOrCreateService(it) }; - } - catch(ex: Throwable) { - StateApp.withContext { - UIDialogs.showDialog( - it, - R.drawable.ic_error, - "Failed to start export due to:\n${ex.message}", null, null, - 0, - UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY) - ); - } - } - } - - - fun removeExport(export: VideoExport) { - _exporting.delete(export); - export.isCancelled = true; - onExportsChanged.emit(); - } - companion object { const val TAG = "StateDownloads"; diff --git a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt index 7dc464a0..62da748b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.views import android.content.Context -import android.content.Intent import android.net.Uri import android.util.AttributeSet import android.view.View @@ -49,6 +48,7 @@ class MonetizationView : LinearLayout { private val _taskLoadMerchandise = TaskHandler>(StateApp.instance.scopeGetter, { url -> val client = ManagedHttpClient(); + Logger.i(TAG, "Loading https://storecache.grayjay.app/StoreData?url=$url") val result = client.get("https://storecache.grayjay.app/StoreData?url=$url") if (!result.isOk) { throw Exception("Failed to retrieve store data."); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt index ae4e6ec9..5be5a4b0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt @@ -16,6 +16,8 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.adapters.AnyAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( @@ -57,10 +59,14 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder< return@changeExternalDownloadDirectory; } - StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + } }; } else { - StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + } } } } From 35d8610c009ab1e577e937660b8d4cb4d0acf65f Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Wed, 26 Jun 2024 17:01:25 +0000 Subject: [PATCH 27/28] Update packageHttp.md --- docs/packages/packageHttp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/packages/packageHttp.md b/docs/packages/packageHttp.md index 98491d14..d6a3bc3c 100644 --- a/docs/packages/packageHttp.md +++ b/docs/packages/packageHttp.md @@ -2,12 +2,12 @@ Package http is the main way for a plugin to make web requests, and is likely a package you will always need. It offers several ways to make web requests as well as websocket connections. -Before you can use http you need to register it in your plugin config. See [Packages](_blank). +Before you can use http you need to register it in your plugin config. See [Packages](/app/src/main/java/com/futo/platformplayer/engine/packages). ## Basic Info Underneath the http package by default exist two web clients. An authenticated client and a unauthenticated client. The authenticated client has will apply headers and cookies if the user is logged in with your plugin. -See [Plugin Authentication](_blank). +See [Plugin Authentication](/docs/Authentication.md). These two clients are always available even when the user is not logged in, meaning it behaves similar to the unauthenticated client and can safely use it either way. >:warning: **Requests are synchronous** From 39e7d64d3f201e4809fb26bdeb133daa3e7e727d Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Wed, 26 Jun 2024 15:03:01 -0500 Subject: [PATCH 28/28] remove save icon after saving --- .../fragment/mainactivity/main/PlaylistFragment.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index 207c635e..af128741 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -162,6 +162,9 @@ class PlaylistFragment : MainFragment() { _fragment.topBar?.assume() ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { StatePlaylists.instance.playlistStore.save(parameter) + _fragment.topBar?.assume()?.setMenuItems( + arrayListOf() + ) UIDialogs.toast("Playlist saved") })) }