mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-25 19:56:53 +00:00
Implemented new channel search.
This commit is contained in:
parent
f6b0778eb6
commit
e0811cfd93
9 changed files with 100 additions and 70 deletions
|
@ -1121,8 +1121,8 @@ class UISlideOverlays {
|
|||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
|
@ -25,6 +26,7 @@ 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.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
|
@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.SearchView
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
|
@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
private var _results: ArrayList<IPlatformContent> = arrayListOf();
|
||||
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
|
@ -68,17 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled)
|
||||
pager =
|
||||
StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
var pager: IPager<IPlatformContent>? = null
|
||||
val query = _query
|
||||
if (!query.isNullOrBlank()) {
|
||||
if(subType != null) {
|
||||
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
|
||||
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
|
||||
} else {
|
||||
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
|
||||
pager = StatePlatform.instance.searchChannel(channel.url, query);
|
||||
}
|
||||
} else {
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
|
||||
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
|
||||
}
|
||||
|
||||
if(pager == null) {
|
||||
if(subType != null)
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
else
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
if(pager == null) {
|
||||
if(subType != null) {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
|
||||
} else {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
|
||||
}
|
||||
}
|
||||
}
|
||||
return pager;
|
||||
}
|
||||
|
@ -145,6 +166,9 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
|
||||
_taskLoadVideos.cancel();
|
||||
|
||||
val pid = channel.id.pluginId
|
||||
_searchView?.visibility = if (pid != null && StatePlatform.instance.getClientOrNull(pid)?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
|
||||
_query = null
|
||||
_channel = channel;
|
||||
_results.clear();
|
||||
_adapterResults?.notifyDataSetChanged();
|
||||
|
@ -152,12 +176,26 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
loadInitial();
|
||||
}
|
||||
|
||||
fun setQuery(query: String) {
|
||||
_query = query
|
||||
loadInitial()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
||||
|
||||
_query = null
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
|
||||
val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
|
||||
onEnter.subscribe {
|
||||
setQuery(it)
|
||||
}
|
||||
visibility = View.GONE
|
||||
}
|
||||
_searchView = searchView
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
|
@ -174,6 +212,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.layoutManager = _glmVideo;
|
||||
_recyclerResults?.addOnScrollListener(_scrollListener);
|
||||
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
|
@ -182,6 +221,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
||||
_recyclerResults = null;
|
||||
_pager = null;
|
||||
_query = null
|
||||
_searchView = null
|
||||
|
||||
_taskLoadVideos.cancel();
|
||||
_nextPageHandler.cancel();
|
||||
|
@ -304,6 +345,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
Logger.i(TAG, "loadInitial")
|
||||
val channel: IPlatformChannel = _channel ?: return;
|
||||
setLoading(true);
|
||||
_taskLoadVideos.run(channel);
|
||||
|
|
|
@ -425,17 +425,15 @@ class ChannelFragment : MainFragment() {
|
|||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
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<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO, channel.url
|
||||
)
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
||||
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
||||
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
||||
|
|
|
@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
private var _sortBy: String? = null;
|
||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||
private var _enabledClientIds: List<String>? = null;
|
||||
private var _channelUrl: String? = null;
|
||||
private var _searchType: SearchType? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
|
@ -98,17 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||
Logger.i(TAG, "Searching for: $query")
|
||||
val channelUrl = _channelUrl;
|
||||
if (channelUrl != null) {
|
||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
} else {
|
||||
when (_searchType)
|
||||
{
|
||||
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
when (_searchType)
|
||||
{
|
||||
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
})
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
|
@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
fun onShown(parameter: Any?) {
|
||||
if(parameter is SuggestionsFragmentData) {
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
setSearchType(parameter.searchType, false)
|
||||
|
||||
fragment.topBar?.apply {
|
||||
|
@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
onFilterClick.subscribe(this) {
|
||||
_overlayContainer.let {
|
||||
val filterValuesCopy = HashMap(_filterValues);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
|
||||
filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
|
||||
if (changed) {
|
||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||
|
@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val commonCapabilities =
|
||||
if(_channelUrl == null)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||
if (sorts.size > 1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
@ -282,15 +271,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) {
|
||||
_channelUrl = channelUrl;
|
||||
|
||||
if (updateResults) {
|
||||
clearResults();
|
||||
loadResults();
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
|
||||
_searchType = searchType
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
|||
import com.futo.platformplayer.views.others.RadioGroupView
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
|
||||
|
||||
class SuggestionsFragment : MainFragment {
|
||||
override val isMainView : Boolean = true;
|
||||
|
@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
|
|||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
private var _query: String? = null;
|
||||
private var _searchType: SearchType = SearchType.VIDEO;
|
||||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
||||
|
||||
|
@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
|
|||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||
storage.add(suggestion);
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
|
||||
}
|
||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||
val index = _suggestions.indexOf(suggestion);
|
||||
|
@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
|
|||
|
||||
if (parameter is SuggestionsFragmentData) {
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
} else if (parameter is SearchType) {
|
||||
_searchType = parameter;
|
||||
_channelUrl = null;
|
||||
}
|
||||
|
||||
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||
|
@ -135,7 +132,7 @@ class SuggestionsFragment : MainFragment {
|
|||
}
|
||||
}
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
|
||||
};
|
||||
|
||||
onTextChange.subscribe(this) {
|
||||
|
|
|
@ -2680,9 +2680,10 @@ class VideoDetailView : ConstraintLayout {
|
|||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
if(it.url.isNotBlank())
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
else
|
||||
} else
|
||||
UIDialogs.appToast("No author url present");
|
||||
}
|
||||
|
||||
|
|
|
@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
|
|||
} else if (parameter is SuggestionsFragmentData) {
|
||||
this.setText(parameter.query);
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
}
|
||||
|
||||
if(currentMain is SuggestionsFragment)
|
||||
|
@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
|
|||
fun clear() {
|
||||
_editSearch?.text?.clear();
|
||||
if (currentMain !is SuggestionsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||
} else {
|
||||
onSearch.emit("");
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ package com.futo.platformplayer.views
|
|||
import android.content.Context
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
|
@ -30,9 +32,26 @@ class SearchView : FrameLayout {
|
|||
textSearch = findViewById(R.id.edit_search)
|
||||
buttonClear = findViewById(R.id.button_clear_search)
|
||||
|
||||
buttonClear.setOnClickListener { textSearch.text = "" };
|
||||
buttonClear.setOnClickListener {
|
||||
textSearch.text = ""
|
||||
textSearch?.clearFocus()
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
|
||||
onSearchChanged.emit("")
|
||||
onEnter.emit("")
|
||||
}
|
||||
textSearch.setOnEditorActionListener { _, i, _ ->
|
||||
if (i == EditorInfo.IME_ACTION_DONE) {
|
||||
textSearch?.clearFocus()
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
|
||||
onEnter.emit(textSearch.text.toString())
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
return@setOnEditorActionListener false
|
||||
|
||||
}
|
||||
textSearch.addTextChangedListener {
|
||||
onSearchChanged.emit(it.toString());
|
||||
buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
|
||||
onSearchChanged.emit(it.toString())
|
||||
};
|
||||
}
|
||||
}
|
|
@ -28,17 +28,14 @@ class SlideUpMenuFilters {
|
|||
private var _changed: Boolean = false;
|
||||
private val _lifecycleScope: CoroutineScope;
|
||||
|
||||
private var _isChannelSearch = false;
|
||||
|
||||
var commonCapabilities: ResultCapabilities? = null;
|
||||
|
||||
|
||||
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false) {
|
||||
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>) {
|
||||
_lifecycleScope = lifecycleScope;
|
||||
_container = container;
|
||||
_enabledClientsIds = enabledClientsIds;
|
||||
_filterValues = filterValues;
|
||||
_isChannelSearch = isChannelSearch;
|
||||
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
|
||||
_slideUpMenuOverlay.onOK.subscribe {
|
||||
onOK.emit(_enabledClientsIds, _changed);
|
||||
|
@ -51,10 +48,7 @@ class SlideUpMenuFilters {
|
|||
private fun updateCommonCapabilities() {
|
||||
_lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val caps = if(!_isChannelSearch)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
|
||||
val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
||||
synchronized(_filterValues) {
|
||||
if (caps != null) {
|
||||
val keysToRemove = arrayListOf<String>();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue