mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-20 03:24:50 +00:00
WIP new subscription system and ui
This commit is contained in:
parent
d8aecd325b
commit
ffa5795cc9
19 changed files with 525 additions and 44 deletions
|
@ -1,8 +1,12 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
|
@ -16,6 +20,7 @@ import com.futo.platformplayer.downloads.VideoLocal
|
|||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
|
@ -45,6 +50,64 @@ class UISlideOverlays {
|
|||
menu.show();
|
||||
}
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
val originalLive = subscription.doFetchLive;
|
||||
val originalStream = subscription.doFetchStreams;
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
|
||||
}, false)));
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
StateSubscriptions.instance.saveSubscription(subscription);
|
||||
menu.hide(true);
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
}
|
||||
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
|
|
@ -9,6 +9,7 @@ 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
|
||||
|
@ -41,6 +42,7 @@ import com.futo.platformplayer.polycentric.PolycentricCache
|
|||
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.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
||||
|
@ -100,6 +102,7 @@ class ChannelFragment : MainFragment() {
|
|||
private var _viewPager: ViewPager2;
|
||||
private var _tabLayoutMediator: TabLayoutMediator;
|
||||
private var _buttonSubscribe: SubscribeButton;
|
||||
private var _buttonSubscriptionSettings: ImageButton;
|
||||
|
||||
private var _overlayContainer: FrameLayout;
|
||||
private var _overlay_loading: LinearLayout;
|
||||
|
@ -160,10 +163,21 @@ class ChannelFragment : MainFragment() {
|
|||
_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);
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
}
|
||||
|
||||
_buttonSubscriptionSettings.setOnClickListener {
|
||||
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);
|
||||
|
@ -246,6 +260,7 @@ class ChannelFragment : MainFragment() {
|
|||
|
||||
if (parameter is String) {
|
||||
_buttonSubscribe.setSubscribeChannel(parameter);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
setPolycentricProfileOr(parameter) {
|
||||
_textChannel.text = "";
|
||||
_textChannelSub.text = "";
|
||||
|
@ -377,6 +392,7 @@ class ChannelFragment : MainFragment() {
|
|||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(channel);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
_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..
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
|||
import com.bumptech.glide.Glide
|
||||
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.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
|
@ -210,6 +211,11 @@ class PostDetailFragment : MainFragment {
|
|||
|
||||
_repliesOverlay = findViewById(R.id.replies_overlay);
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
//TODO: add overlay to layout
|
||||
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
};
|
||||
|
||||
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
|
||||
root.removeView(layoutTop);
|
||||
_commentsList.setPrependedView(layoutTop);
|
||||
|
|
|
@ -314,6 +314,11 @@ class VideoDetailView : ConstraintLayout {
|
|||
_layoutMonetization.visibility = View.GONE;
|
||||
_player.attachPlayer();
|
||||
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
};
|
||||
|
||||
_container_content_liveChat.onRaidNow.subscribe {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
|
|
|
@ -12,6 +12,13 @@ import java.time.OffsetDateTime
|
|||
class Subscription {
|
||||
var channel: SerializedChannel;
|
||||
|
||||
//Settings
|
||||
var doNotifications: Boolean = false;
|
||||
var doFetchLive: Boolean = false;
|
||||
var doFetchStreams: Boolean = true;
|
||||
var doFetchVideos: Boolean = true;
|
||||
var doFetchPosts: Boolean = false;
|
||||
|
||||
//Last found content
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
|
||||
|
@ -48,8 +55,4 @@ class Subscription {
|
|||
fun updateChannel(channel: IPlatformChannel) {
|
||||
this.channel = SerializedChannel.fromChannel(channel);
|
||||
}
|
||||
|
||||
fun updateVideoStatus(allVideos: List<IPlatformContent>? = null, liveStreams: List<IPlatformContent>? = null) {
|
||||
|
||||
}
|
||||
}
|
|
@ -651,11 +651,8 @@ class StatePlatform {
|
|||
return _scope.async { getChannelLive(url, updateSubscriptions) };
|
||||
}
|
||||
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - getChannelVideos");
|
||||
val baseClient = getChannelClient(channelUrl, ignorePlugins);
|
||||
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
val clientCapabilities = baseClient.getChannelCapabilities();
|
||||
|
||||
val client = if(usePooledClients > 1)
|
||||
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
||||
else baseClient;
|
||||
|
@ -770,6 +767,20 @@ class StatePlatform {
|
|||
|
||||
return pagerResult;
|
||||
}
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - getChannelVideos");
|
||||
val baseClient = getChannelClient(channelUrl, ignorePlugins);
|
||||
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, ignorePlugins);
|
||||
}
|
||||
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
|
||||
val client = getChannelClient(channelUrl);
|
||||
return getChannelContent(client, channelUrl, type, ordering);
|
||||
}
|
||||
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
|
||||
val client = _channelClientPool.getClientPooled(baseClient, Settings.instance.subscriptions.getSubscriptionsConcurrency());
|
||||
return client.getChannelContents(channelUrl, type, ordering) ;
|
||||
}
|
||||
|
||||
|
||||
fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel {
|
||||
val channel = getChannelClient(url).getChannel(url);
|
||||
|
|
|
@ -144,6 +144,33 @@ class StatePolycentric {
|
|||
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun getChannelUrls(url: String, channelId: PlatformID? = null): List<String> {
|
||||
|
||||
var polycentricProfile: PolycentricProfile? = null;
|
||||
try {
|
||||
polycentricProfile = PolycentricCache.instance.getCachedProfile(url)?.profile;
|
||||
if (polycentricProfile == null && channelId != null) {
|
||||
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
|
||||
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId) }?.profile;
|
||||
} else {
|
||||
Logger.i("StateSubscriptions", "Get polycentric profile cached");
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.w(StateSubscriptions.TAG, "Polycentric getCachedProfile failed for subscriptions", ex);
|
||||
//TODO: Some way to communicate polycentric failing without blocking here
|
||||
}
|
||||
if(polycentricProfile != null) {
|
||||
val urls = polycentricProfile.ownedClaims.groupBy { it.claim.claimType }
|
||||
.mapNotNull { it.value.firstOrNull()?.claim?.resolveChannelUrl() }.toMutableList();
|
||||
if(urls.any { it.equals(url, true) })
|
||||
return urls;
|
||||
else
|
||||
return listOf(url) + urls;
|
||||
}
|
||||
else
|
||||
return listOf(url);
|
||||
}
|
||||
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
|
||||
//TODO: Currently abusing subscription concurrency for parallelism
|
||||
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
|
||||
|
|
|
@ -29,6 +29,8 @@ import com.futo.platformplayer.stores.FragmentedStorage
|
|||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
|
@ -53,7 +55,6 @@ class StateSubscriptions {
|
|||
private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency());
|
||||
private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>();
|
||||
|
||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
||||
|
||||
private var _globalSubscriptionsLock = Object();
|
||||
private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null;
|
||||
|
@ -62,6 +63,8 @@ class StateSubscriptions {
|
|||
var globalSubscriptionExceptions: List<Throwable> = listOf()
|
||||
private set;
|
||||
|
||||
private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SIMPLE;
|
||||
|
||||
private var _lastGlobalSubscriptionProgress: Int = 0;
|
||||
private var _lastGlobalSubscriptionTotal: Int = 0;
|
||||
val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>();
|
||||
|
@ -69,6 +72,8 @@ class StateSubscriptions {
|
|||
val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>();
|
||||
val onGlobalSubscriptionsException = Event1<List<Throwable>>();
|
||||
|
||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
||||
|
||||
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
|
||||
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
|
||||
}
|
||||
|
@ -223,37 +228,27 @@ class StateSubscriptions {
|
|||
}
|
||||
|
||||
fun getSubscriptionRequestCount(): Map<JSClient, Int> {
|
||||
val subs = getSubscriptions();
|
||||
val pluginReqCounts = mutableMapOf<JSClient, Int>();
|
||||
return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
|
||||
.countRequests(getSubscriptions());
|
||||
}
|
||||
|
||||
for(sub in subs) {
|
||||
val client = StatePlatform.instance.getChannelClientOrNull(sub.channel.url);
|
||||
if(client !is JSClient)
|
||||
continue;
|
||||
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
||||
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
|
||||
|
||||
val channelCaps = client.getChannelCapabilities();
|
||||
if(!pluginReqCounts.containsKey(client))
|
||||
pluginReqCounts[client] = 1;
|
||||
else
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
|
||||
if(channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.shouldFetchStreams())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
if(channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.shouldFetchLiveStreams())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
if(channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.shouldFetchPosts())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
algo.onProgress.subscribe { progress, total ->
|
||||
onProgress?.invoke(progress, total);
|
||||
}
|
||||
return pluginReqCounts;
|
||||
}
|
||||
algo.onNewCacheHit.subscribe { sub, content ->
|
||||
|
||||
fun getSubscriptionsFeed(allowFailure: Boolean = false): IPager<IPlatformContent> {
|
||||
val result = getSubscriptionsFeedWithExceptions(allowFailure, true);
|
||||
if(result.second.any())
|
||||
throw result.second.first();
|
||||
return result.first;
|
||||
}
|
||||
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
||||
}
|
||||
|
||||
val subUrls = getSubscriptions().associateWith {
|
||||
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id)
|
||||
};
|
||||
|
||||
val result = algo.getSubscriptions(subUrls);
|
||||
return Pair(result.pager, result.exceptions);
|
||||
/*
|
||||
val subsPager: Array<IPager<IPlatformContent>>;
|
||||
val exs: ArrayList<Throwable> = arrayListOf();
|
||||
|
||||
|
@ -384,6 +379,7 @@ class StateSubscriptions {
|
|||
pager.initialize();
|
||||
//return Pair(pager, exs);
|
||||
return Pair(DedupContentPager(pager), exs);
|
||||
*/
|
||||
}
|
||||
|
||||
//New Migration
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package com.futo.platformplayer.subscription
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.resolveChannelUrl
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toSafeFileName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
|
||||
class CachedSubscriptionAlgorithm(pageSize: Int = 150, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, threadPool: ForkJoinPool? = null)
|
||||
: SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
|
||||
|
||||
private val _pageSize: Int = pageSize;
|
||||
|
||||
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
|
||||
return mapOf<JSClient, Int>();
|
||||
}
|
||||
|
||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||
val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = ChannelContentCache.instance._channelContents
|
||||
.filter { validSubIds.contains(it.key) }
|
||||
.map { it.value };
|
||||
|
||||
val items = validStores.flatMap { it.getItems() }
|
||||
.sortedByDescending { it.datetime };
|
||||
|
||||
return Result(DedupContentPager(PlatformContentPager(items, Math.min(_pageSize, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }), listOf());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package com.futo.platformplayer.subscription
|
||||
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
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.DedupContentPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.findNonRuntimeException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.lang.Exception
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class SimpleSubscriptionAlgorithm(
|
||||
scope: CoroutineScope,
|
||||
allowFailure: Boolean = false,
|
||||
withCacheFallback: Boolean = true,
|
||||
threadPool: ForkJoinPool? = null
|
||||
): SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
|
||||
|
||||
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
|
||||
val pluginReqCounts = mutableMapOf<JSClient, Int>();
|
||||
|
||||
for(sub in subs) {
|
||||
for(subUrl in sub.value) {
|
||||
val client = StatePlatform.instance.getChannelClientOrNull(sub.key.channel.url);
|
||||
if (client !is JSClient)
|
||||
continue;
|
||||
|
||||
val channelCaps = client.getChannelCapabilities();
|
||||
if (!pluginReqCounts.containsKey(client))
|
||||
pluginReqCounts[client] = 1;
|
||||
else
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
|
||||
if (channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.key.shouldFetchStreams())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
if (channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.key.shouldFetchLiveStreams())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
if (channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.key.shouldFetchPosts())
|
||||
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
|
||||
}
|
||||
}
|
||||
return pluginReqCounts;
|
||||
}
|
||||
|
||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||
val subsPager: Array<IPager<IPlatformContent>>;
|
||||
val exs: ArrayList<Throwable> = arrayListOf();
|
||||
|
||||
val tasks = mutableListOf<ForkJoinTask<Pair<Subscription, IPager<IPlatformContent>?>>>();
|
||||
var finished = 0;
|
||||
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
|
||||
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
|
||||
val failedPlugins = arrayListOf<String>();
|
||||
for (sub in subs.filter { StatePlatform.instance.hasEnabledChannelClient(it.key.channel.url) })
|
||||
tasks.add(getSubscription(sub.key, sub.value, failedPlugins){ channelEx ->
|
||||
finished++;
|
||||
onProgress.emit(finished, tasks.size);
|
||||
|
||||
val ex = channelEx?.cause;
|
||||
|
||||
if(channelEx != null) {
|
||||
synchronized(exceptionMap) {
|
||||
exceptionMap.put(sub.key, channelEx);
|
||||
}
|
||||
if(ex is ScriptCaptchaRequiredException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a captcha issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptionsgnoring plugin [${ex.config.name}] due to Captcha");
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(ex is ScriptCriticalException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a critical issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
val timeTotal = measureTimeMillis {
|
||||
val taskResults = arrayListOf<IPager<IPlatformContent>>();
|
||||
for(task in tasks) {
|
||||
try {
|
||||
val result = task.get();
|
||||
if(result != null) {
|
||||
if(result.second != null)
|
||||
taskResults.add(result.second!!);
|
||||
if(exceptionMap.containsKey(result.first)) {
|
||||
val ex = exceptionMap[result.first];
|
||||
if(ex != null) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex);
|
||||
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: ExecutionException) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex.cause);
|
||||
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
};
|
||||
}
|
||||
subsPager = taskResults.toTypedArray();
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
|
||||
if(subsPager.size <= 0 && exs.any())
|
||||
throw exs.first();
|
||||
|
||||
Logger.i(StateSubscriptions.TAG, "Subscription pager with ${subsPager.size} channels");
|
||||
val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
|
||||
pager.initialize();
|
||||
//return Pair(pager, exs);
|
||||
return Result(DedupContentPager(pager), exs);
|
||||
}
|
||||
|
||||
private fun getSubscription(sub: Subscription, urls: List<String>, failedPlugins: List<String>, onFinished: (ChannelException?)->Unit): ForkJoinTask<Pair<Subscription, IPager<IPlatformContent>?>> {
|
||||
return threadPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
|
||||
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
|
||||
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
for(url in urls) {
|
||||
try {
|
||||
val platformClient = StatePlatform.instance.getChannelClientOrNull(url, toIgnore) ?: continue;
|
||||
val time = measureTimeMillis {
|
||||
pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore);
|
||||
|
||||
pager = ChannelContentCache.cachePagerResults(scope, pager!!) {
|
||||
onNewCacheHit.emit(sub, it);
|
||||
};
|
||||
|
||||
onFinished(null);
|
||||
}
|
||||
Logger.i(
|
||||
"StateSubscriptions",
|
||||
"Subscription [${sub.channel.name}] results in ${time}ms"
|
||||
);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(StateSubscriptions.TAG, "Subscription [${sub.channel.name}] failed", ex);
|
||||
val channelEx = ChannelException(sub.channel, ex);
|
||||
onFinished(channelEx);
|
||||
if(!withCacheFallback)
|
||||
throw channelEx;
|
||||
else {
|
||||
Logger.i(StateSubscriptions.TAG, "Channel ${sub.channel.name} failed, substituting with cache");
|
||||
pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(pager == null)
|
||||
throw IllegalStateException("Uncaught nullable pager");
|
||||
return@submit Pair(sub, pager);
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package com.futo.platformplayer.subscription
|
||||
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
|
||||
class SmartSubscriptionAlgorithm(
|
||||
scope: CoroutineScope,
|
||||
allowFailure: Boolean = false,
|
||||
withCacheFallback: Boolean = true,
|
||||
threadPool: ForkJoinPool? = null
|
||||
): SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
|
||||
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package com.futo.platformplayer.subscription
|
||||
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.lang.Exception
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
|
||||
abstract class SubscriptionFetchAlgorithm(
|
||||
val scope: CoroutineScope,
|
||||
val allowFailure: Boolean = false,
|
||||
val withCacheFallback: Boolean = true,
|
||||
private val _threadPool: ForkJoinPool? = null
|
||||
) {
|
||||
val threadPool: ForkJoinPool get() = _threadPool ?: throw IllegalStateException("Require thread pool parameter");
|
||||
val onNewCacheHit = Event2<Subscription, IPlatformContent>();
|
||||
val onProgress = Event2<Int, Int>();
|
||||
|
||||
fun countRequests(subs: List<Subscription>): Map<JSClient, Int> = countRequests(subs.associateWith { listOf(it.channel.url) });
|
||||
abstract fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int>;
|
||||
|
||||
fun getSubscriptions(subs: List<Subscription>): Result = getSubscriptions(subs.associateWith { listOf(it.channel.url) });
|
||||
abstract fun getSubscriptions(subs: Map<Subscription, List<String>>): Result;
|
||||
|
||||
|
||||
class Result(
|
||||
val pager: IPager<IPlatformContent>,
|
||||
val exceptions: List<Throwable>
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm {
|
||||
return when(algo) {
|
||||
SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(150, scope, allowFailure, withCacheFallback, pool);
|
||||
SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
|
||||
SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
|
||||
else -> throw IllegalStateException("Unknown algorithm ${algo}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.futo.platformplayer.subscription
|
||||
|
||||
enum class SubscriptionFetchAlgorithms(val value: Int) {
|
||||
CACHE(1),
|
||||
SIMPLE(2),
|
||||
SMART(3);
|
||||
}
|
|
@ -11,6 +11,7 @@ import com.futo.platformplayer.R
|
|||
class SlideUpMenuGroup : LinearLayout {
|
||||
|
||||
private lateinit var title: TextView;
|
||||
private lateinit var description: TextView;
|
||||
private lateinit var itemContainer: LinearLayout;
|
||||
private var parentClickListener: (()->Unit)? = null;
|
||||
private val items: List<SlideUpMenuItem>;
|
||||
|
@ -28,6 +29,16 @@ class SlideUpMenuGroup : LinearLayout {
|
|||
groupTag = tag;
|
||||
this.items = items.toList();
|
||||
addItems(items);
|
||||
description.visibility = View.GONE;
|
||||
}
|
||||
constructor(context: Context, titleText: String, descriptionText: String, tag: Any, items: List<SlideUpMenuItem>) : super(context){
|
||||
init();
|
||||
title.text = titleText;
|
||||
groupTag = tag;
|
||||
this.items = items.toList();
|
||||
addItems(items);
|
||||
description.text = descriptionText;
|
||||
description.visibility = View.VISIBLE;
|
||||
}
|
||||
|
||||
constructor(context: Context, titleText: String, tag: Any, vararg items: SlideUpMenuItem)
|
||||
|
@ -37,6 +48,7 @@ class SlideUpMenuGroup : LinearLayout {
|
|||
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_group, this, true);
|
||||
|
||||
title = findViewById(R.id.slide_up_menu_group_title);
|
||||
description = findViewById(R.id.slide_up_menu_group_description);
|
||||
itemContainer = findViewById(R.id.slide_up_menu_group_items);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,13 +11,12 @@ import android.widget.LinearLayout
|
|||
import android.widget.TextView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
class SubscribeButton : LinearLayout {
|
||||
private val _root: FrameLayout;
|
||||
|
@ -29,12 +28,15 @@ class SubscribeButton : LinearLayout {
|
|||
var url : String? = null
|
||||
private set;
|
||||
|
||||
private var _isSubscribed: Boolean = false;
|
||||
var isSubscribed: Boolean = false
|
||||
private set;
|
||||
|
||||
private val _subscribeTask = if (!isInEditMode) {
|
||||
TaskHandler<String, IPlatformChannel>(StateApp.instance.scopeGetter, StatePlatform.instance::getChannelLive).success(::handleSubscribe)
|
||||
} else { null };
|
||||
|
||||
val onSubscribed = Event1<Subscription>();
|
||||
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.button_subscribe, this, true);
|
||||
|
@ -69,9 +71,10 @@ class SubscribeButton : LinearLayout {
|
|||
|
||||
private fun handleSubscribe(channel: IPlatformChannel) {
|
||||
setIsLoading(false);
|
||||
StateSubscriptions.instance.addSubscription(channel);
|
||||
val sub = StateSubscriptions.instance.addSubscription(channel);
|
||||
UIDialogs.toast(context, context.getString(R.string.subscribed_to) + channel.name);
|
||||
setIsSubscribed(true);
|
||||
onSubscribed.emit(sub);
|
||||
}
|
||||
private fun handleUnSubscribe(url: String) {
|
||||
setIsLoading(false);
|
||||
|
@ -118,6 +121,6 @@ class SubscribeButton : LinearLayout {
|
|||
else
|
||||
_root.visibility = INVISIBLE;
|
||||
|
||||
_isSubscribed = isSubcribed;
|
||||
isSubscribed = isSubcribed;
|
||||
}
|
||||
}
|
10
app/src/main/res/drawable/ic_live_tv.xml
Normal file
10
app/src/main/res/drawable/ic_live_tv.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M380,620L660,440L380,260L380,620ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,680Q880,713 856.5,736.5Q833,760 800,760L640,760L640,840L320,840ZM160,680L800,680Q800,680 800,680Q800,680 800,680L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680ZM160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L160,680Z"/>
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_notifications.xml
Normal file
10
app/src/main/res/drawable/ic_notifications.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,460L480,460L480,460L480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM320,680L640,680L640,400Q640,334 593,287Q546,240 480,240Q414,240 367,287Q320,334 320,400L320,680Z"/>
|
||||
</vector>
|
|
@ -82,7 +82,7 @@
|
|||
tools:text="CHANNEL NAME"
|
||||
app:layout_constraintLeft_toRightOf="@id/creator_thumbnail"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_subscribe" />
|
||||
app:layout_constraintRight_toLeftOf="@id/button_sub_settings" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_metadata"
|
||||
|
@ -97,8 +97,18 @@
|
|||
android:ellipsize="end"
|
||||
tools:text="17 videos"
|
||||
app:layout_constraintLeft_toRightOf="@id/creator_thumbnail"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_subscribe"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_sub_settings"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_sub_settings"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintTop_toTopOf="@id/button_subscribe"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_subscribe"
|
||||
android:src="@drawable/ic_settings" />
|
||||
|
||||
<com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
android:id="@+id/button_subscribe"
|
||||
|
|
|
@ -15,6 +15,14 @@
|
|||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_semibold"
|
||||
android:textSize="11dp" />
|
||||
<TextView
|
||||
android:id="@+id/slide_up_menu_group_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Recently Used Playlist"
|
||||
android:textColor="@color/text_color_tinted"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textSize="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/slide_up_menu_group_items"
|
||||
|
|
Loading…
Add table
Reference in a new issue