mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 869b1fc15e | |||
| ce2a2f8582 | |||
| 7b355139fb | |||
| b14518edb1 | |||
| 7d64003d1c |
@@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var home = HomeSettings();
|
||||
@Serializable
|
||||
class HomeSettings {
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
@@ -216,6 +216,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||
var showHomeFilters: Boolean = true;
|
||||
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||
var showHomeFiltersPluginNames: Boolean = false;
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@@ -1148,7 +1148,7 @@ class UISlideOverlays {
|
||||
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
|
||||
tag = "",
|
||||
call = {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, {
|
||||
val selected = it
|
||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||
.filter { it != null }
|
||||
@@ -1156,7 +1156,7 @@ class UISlideOverlays {
|
||||
.toList();
|
||||
|
||||
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||
}
|
||||
});
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
@@ -1164,29 +1164,40 @@ class UISlideOverlays {
|
||||
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
||||
}
|
||||
|
||||
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
|
||||
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit, description: String? = null) {
|
||||
val selection: MutableList<Any> = mutableListOf();
|
||||
|
||||
var overlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
||||
options.map { SlideUpMenuItem(
|
||||
listOf(
|
||||
if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null,
|
||||
).filterNotNull() +
|
||||
(options.map { SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_move_up,
|
||||
it.first,
|
||||
"",
|
||||
tag = it.second,
|
||||
call = {
|
||||
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
|
||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||
if(!selection.contains(it.second))
|
||||
if(!selection.contains(it.second)) {
|
||||
selection.add(it.second);
|
||||
} else
|
||||
if(overlayItem != null) {
|
||||
overlayItem.setSubText(selection.indexOf(it.second).toString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selection.remove(it.second);
|
||||
if(overlayItem != null) {
|
||||
overlayItem.setSubText("");
|
||||
}
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
});
|
||||
}));
|
||||
overlay.onOK.subscribe {
|
||||
onOrdered.invoke(selection);
|
||||
overlay.hide();
|
||||
|
||||
@@ -27,14 +27,18 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||
fun getRandomString(sizeOfRandomString: Int): String {
|
||||
@@ -279,3 +283,34 @@ fun ByteBuffer.toUtf8String(): String {
|
||||
get(remainingBytes)
|
||||
return String(remainingBytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
|
||||
fun ByteArray.toGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val gzipTimeStart = OffsetDateTime.now();
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(outputStream).use { gzip ->
|
||||
gzip.write(this)
|
||||
}
|
||||
val result = outputStream.toByteArray();
|
||||
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
|
||||
return result;
|
||||
}
|
||||
|
||||
fun ByteArray.fromGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val inputStream = ByteArrayInputStream(this)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
GZIPInputStream(inputStream).use { gzip ->
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
while (gzip.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
+1
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.contents
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
interface IPlatformContent {
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -20,6 +21,7 @@ open class SerializedPlatformVideo(
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
override val datetime: OffsetDateTime? = null,
|
||||
override val url: String,
|
||||
override val shareUrl: String = "",
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1
|
||||
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
|
||||
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
|
||||
*/
|
||||
interface IRefreshPager<T> {
|
||||
interface IRefreshPager<T>: IPager<T> {
|
||||
val onPagerChanged: Event1<IPager<T>>;
|
||||
val onPagerError: Event1<Throwable>;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.futo.platformplayer.api.media.structures
|
||||
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager.Window
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
/**
|
||||
@@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger
|
||||
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
|
||||
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
|
||||
*/
|
||||
class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
||||
private val _pager: IPager<T>;
|
||||
open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
|
||||
protected var _pager: IPager<T>;
|
||||
val previousResults = arrayListOf<T>();
|
||||
|
||||
constructor(subPager: IPager<T>) {
|
||||
@@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
||||
return previousResults;
|
||||
}
|
||||
|
||||
fun getWindow(): Window<T> {
|
||||
override fun getWindow(): Window<T> {
|
||||
return Window(this);
|
||||
}
|
||||
|
||||
@@ -95,4 +97,118 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
||||
return ReusablePager(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class ReusableRefreshPager<T>: INestedPager<T>, IReusablePager<T> {
|
||||
protected var _pager: IRefreshPager<T>;
|
||||
val previousResults = arrayListOf<T>();
|
||||
|
||||
private var _currentPage: IPager<T>;
|
||||
|
||||
|
||||
val onPagerChanged = Event1<IPager<T>>()
|
||||
val onPagerError = Event1<Throwable>()
|
||||
|
||||
constructor(subPager: IRefreshPager<T>) {
|
||||
this._pager = subPager;
|
||||
_currentPage = this;
|
||||
synchronized(previousResults) {
|
||||
previousResults.addAll(subPager.getResults());
|
||||
}
|
||||
_pager.onPagerError.subscribe(onPagerError::emit);
|
||||
_pager.onPagerChanged.subscribe {
|
||||
_currentPage = it;
|
||||
synchronized(previousResults) {
|
||||
previousResults.clear();
|
||||
previousResults.addAll(it.getResults());
|
||||
}
|
||||
|
||||
onPagerChanged.emit(_currentPage);
|
||||
};
|
||||
}
|
||||
|
||||
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||
if(query(_pager))
|
||||
return _pager;
|
||||
else if(_pager is INestedPager<*>)
|
||||
return (_pager as INestedPager<T>).findPager(query);
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _pager.hasMorePages();
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
_pager.nextPage();
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
val results = _pager.getResults();
|
||||
synchronized(previousResults) {
|
||||
previousResults.addAll(results);
|
||||
}
|
||||
return previousResults;
|
||||
}
|
||||
|
||||
override fun getWindow(): RefreshWindow<T> {
|
||||
return RefreshWindow(this);
|
||||
}
|
||||
|
||||
|
||||
class RefreshWindow<T>: IPager<T>, INestedPager<T>, IRefreshPager<T> {
|
||||
private val _parent: ReusableRefreshPager<T>;
|
||||
private var _position: Int = 0;
|
||||
private var _read: Int = 0;
|
||||
|
||||
private var _currentResults: List<T>;
|
||||
|
||||
override val onPagerChanged = Event1<IPager<T>>();
|
||||
override val onPagerError = Event1<Throwable>();
|
||||
|
||||
|
||||
override fun getCurrentPager(): IPager<T> {
|
||||
return _parent.getWindow();
|
||||
}
|
||||
|
||||
constructor(parent: ReusableRefreshPager<T>) {
|
||||
_parent = parent;
|
||||
|
||||
synchronized(_parent.previousResults) {
|
||||
_currentResults = _parent.previousResults.toList();
|
||||
_read += _currentResults.size;
|
||||
}
|
||||
parent.onPagerChanged.subscribe(onPagerChanged::emit);
|
||||
parent.onPagerError.subscribe(onPagerError::emit);
|
||||
}
|
||||
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _parent.previousResults.size > _read || _parent.hasMorePages();
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
synchronized(_parent.previousResults) {
|
||||
if (_parent.previousResults.size <= _read) {
|
||||
_parent.nextPage();
|
||||
_parent.getResults();
|
||||
}
|
||||
_currentResults = _parent.previousResults.drop(_read).toList();
|
||||
_read += _currentResults.size;
|
||||
}
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
return _currentResults;
|
||||
}
|
||||
|
||||
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||
return _parent.findPager(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IReusablePager<T>: IPager<T> {
|
||||
fun getWindow(): IPager<T>;
|
||||
}
|
||||
+17
-11
@@ -21,6 +21,8 @@ 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.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
@@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
|
||||
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
||||
|
||||
private var lastDownloads: List<VideoLocal>? = null;
|
||||
private var ordering: String? = "nameAsc";
|
||||
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
|
||||
|
||||
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
|
||||
inflater.inflate(R.layout.fragment_downloads, this);
|
||||
_frag = frag;
|
||||
|
||||
if(ordering.value.isNullOrBlank())
|
||||
ordering.value = "nameAsc";
|
||||
|
||||
_usageUsed = findViewById(R.id.downloads_usage_used);
|
||||
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
||||
_usageProgress = findViewById(R.id.downloads_usage_progress);
|
||||
@@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() {
|
||||
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
};
|
||||
spinnerSortBy.setSelection(0);
|
||||
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
|
||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||
when(pos) {
|
||||
0 -> ordering = "nameAsc"
|
||||
1 -> ordering = "nameDesc"
|
||||
2 -> ordering = "downloadDateAsc"
|
||||
3 -> ordering = "downloadDateDesc"
|
||||
4 -> ordering = "releasedAsc"
|
||||
5 -> ordering = "releasedDesc"
|
||||
else -> ordering = null
|
||||
0 -> ordering.setAndSave("nameAsc")
|
||||
1 -> ordering.setAndSave("nameDesc")
|
||||
2 -> ordering.setAndSave("downloadDateAsc")
|
||||
3 -> ordering.setAndSave("downloadDateDesc")
|
||||
4 -> ordering.setAndSave("releasedAsc")
|
||||
5 -> ordering.setAndSave("releasedDesc")
|
||||
else -> ordering.setAndSave("")
|
||||
}
|
||||
updateContentFilters()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
};
|
||||
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
|
||||
|
||||
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
||||
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
||||
@@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() {
|
||||
var vidsToReturn = vids;
|
||||
if(!_listDownloadSearch.text.isNullOrEmpty())
|
||||
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
|
||||
if(!ordering.isNullOrEmpty()) {
|
||||
vidsToReturn = when(ordering){
|
||||
if(!ordering.value.isNullOrEmpty()) {
|
||||
vidsToReturn = when(ordering.value){
|
||||
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
||||
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
||||
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
@@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
private val _scrollListener: RecyclerView.OnScrollListener;
|
||||
private var _automaticNextPageCounter = 0;
|
||||
private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000);
|
||||
|
||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
||||
this.fragment = fragment;
|
||||
@@ -129,6 +136,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
else
|
||||
@@ -182,29 +190,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||
val height = resources.displayMetrics.heightPixels;
|
||||
|
||||
val layoutManager = recyclerData.layoutManager
|
||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||
|
||||
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
|
||||
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
|
||||
val itemHeight = firstVisibleView?.height ?: 0
|
||||
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight
|
||||
val recyclerViewHeight = _recyclerResults.height
|
||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
|
||||
occupiedSpace >= recyclerViewHeight
|
||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
||||
false;
|
||||
}
|
||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
||||
false;
|
||||
} else {
|
||||
false
|
||||
true;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
||||
if (!canScroll || filteredResults.isEmpty()) {
|
||||
_automaticNextPageCounter++
|
||||
if(_automaticNextPageCounter <= 4)
|
||||
loadNextPage()
|
||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
||||
if(_automaticNextPageCounter > 0) {
|
||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(true);
|
||||
}
|
||||
delay(backoff.toLong());
|
||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
loadNextPage();
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
}
|
||||
fun resetAutomaticNextPageCounter(){
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
|
||||
protected fun setTextCentered(text: String?) {
|
||||
_textCentered.text = text;
|
||||
|
||||
+123
-19
@@ -5,29 +5,38 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.allViews
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
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.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReusablePager
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager
|
||||
import com.futo.platformplayer.api.media.structures.ReusableRefreshPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
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.announcements.AnnouncementView
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.OffsetDateTime
|
||||
@@ -39,6 +48,12 @@ class HomeFragment : MainFragment() {
|
||||
|
||||
private var _view: HomeView? = null;
|
||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
|
||||
|
||||
private var _toggleRecent = false;
|
||||
private var _toggleWatched = false;
|
||||
private var _togglePluginsDisabled = mutableListOf<String>();
|
||||
|
||||
|
||||
fun reloadFeed() {
|
||||
_view?.reloadFeed()
|
||||
@@ -64,7 +79,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = HomeView(this, inflater, _cachedRecyclerData);
|
||||
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
|
||||
_view = view;
|
||||
return view;
|
||||
}
|
||||
@@ -82,6 +97,7 @@ class HomeFragment : MainFragment() {
|
||||
val view = _view;
|
||||
if (view != null) {
|
||||
_cachedRecyclerData = view.recyclerData;
|
||||
_cachedLastPager = view.lastPager;
|
||||
view.cleanup();
|
||||
_view = null;
|
||||
}
|
||||
@@ -91,6 +107,7 @@ class HomeFragment : MainFragment() {
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class HomeView : ContentFeedView<HomeFragment> {
|
||||
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
|
||||
@@ -100,11 +117,22 @@ class HomeFragment : MainFragment() {
|
||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
var lastPager: IReusablePager<IPlatformContent>? = null;
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
lastPager = cachedLastPager
|
||||
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
||||
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.success {
|
||||
val wrappedPager = if(it is IRefreshPager)
|
||||
ReusableRefreshPager(it);
|
||||
else
|
||||
ReusablePager(it);
|
||||
lastPager = wrappedPager;
|
||||
resetAutomaticNextPageCounter();
|
||||
loadedResult(wrappedPager.getWindow());
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<ScriptExecutionException> {
|
||||
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
|
||||
@@ -207,22 +235,94 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
private val _filterLock = Object();
|
||||
private var _toggleRecent = false;
|
||||
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
|
||||
fun initializeToolbarContent() {
|
||||
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing
|
||||
/*
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
synchronized(_filterLock) {
|
||||
_toggleBar?.setToggles(
|
||||
//TODO: loadResults needs to be replaced with an internal reload of the current content
|
||||
ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) }
|
||||
)
|
||||
}
|
||||
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
||||
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
*/
|
||||
if(Settings.instance.home.showHomeFilters) {
|
||||
|
||||
if (!_togglesConfig.any()) {
|
||||
_togglesConfig.set("today", "watched", "plugins");
|
||||
_togglesConfig.save();
|
||||
}
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams =
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
|
||||
synchronized(_filterLock) {
|
||||
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
|
||||
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
|
||||
(StatePlatform.instance.getEnabledClients()
|
||||
.filter { it is JSClient && it.enableInHome }
|
||||
.map { plugin ->
|
||||
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||
var dontSwap = false;
|
||||
if (active) {
|
||||
if (fragment._togglePluginsDisabled.contains(plugin.id))
|
||||
fragment._togglePluginsDisabled.remove(plugin.id);
|
||||
} else {
|
||||
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
|
||||
val enabledClients = StatePlatform.instance.getEnabledClients();
|
||||
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
|
||||
if(availableAfterDisable > 0)
|
||||
fragment._togglePluginsDisabled.add(plugin.id);
|
||||
else {
|
||||
UIDialogs.appToast("Home needs atleast 1 plugin active");
|
||||
dontSwap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!dontSwap)
|
||||
reloadForFilters();
|
||||
else {
|
||||
view.setToggle(!active);
|
||||
}
|
||||
}).withTag("plugins")
|
||||
})
|
||||
else listOf())
|
||||
val buttons = (listOf<ToggleBar.Toggle?>(
|
||||
(if (_togglesConfig.contains("today"))
|
||||
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
|
||||
fragment._toggleRecent = active; reloadForFilters()
|
||||
}
|
||||
.withTag("today") else null),
|
||||
(if (_togglesConfig.contains("watched"))
|
||||
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
|
||||
fragment._toggleWatched = active; reloadForFilters()
|
||||
}
|
||||
.withTag("watched") else null),
|
||||
).filterNotNull() + buttonsPlugins)
|
||||
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
|
||||
|
||||
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
|
||||
showOrderOverlay(_overlayContainer,
|
||||
"Visible home filters",
|
||||
listOf(
|
||||
Pair("Plugins", "plugins"),
|
||||
Pair("Today", "today"),
|
||||
Pair("Watched", "watched")
|
||||
),
|
||||
{
|
||||
val newArray = it.map { it.toString() }.toTypedArray();
|
||||
_togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none")));
|
||||
_togglesConfig.save();
|
||||
initializeToolbarContent();
|
||||
},
|
||||
"Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings"
|
||||
);
|
||||
}).asButton();
|
||||
|
||||
val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray();
|
||||
_toggleBar?.setToggles(*buttonsOrder);
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
}
|
||||
}
|
||||
fun reloadForFilters() {
|
||||
lastPager?.let { loadedResult(it.getWindow()) };
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
@@ -232,7 +332,11 @@ class HomeFragment : MainFragment() {
|
||||
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
||||
return@filter false;
|
||||
|
||||
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) {
|
||||
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
|
||||
return@filter false;
|
||||
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
|
||||
return@filter false;
|
||||
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
|
||||
return@filter false;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
|
||||
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
}
|
||||
|
||||
override fun onVideoOptions(video: IPlatformVideo) {
|
||||
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||
}
|
||||
override fun onVideoClicked(video: IPlatformVideo) {
|
||||
val playlist = _playlist;
|
||||
if (playlist != null) {
|
||||
|
||||
+16
-13
@@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.SearchView
|
||||
import com.futo.platformplayer.views.adapters.*
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
@@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() {
|
||||
|
||||
private var _listPlaylistsSearch: EditText;
|
||||
|
||||
private var _ordering: String? = null;
|
||||
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
|
||||
|
||||
|
||||
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
@@ -145,24 +147,25 @@ class PlaylistsFragment : MainFragment() {
|
||||
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
};
|
||||
spinnerSortBy.setSelection(0);
|
||||
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
|
||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||
when(pos) {
|
||||
0 -> _ordering = "nameAsc"
|
||||
1 -> _ordering = "nameDesc"
|
||||
2 -> _ordering = "dateEditAsc"
|
||||
3 -> _ordering = "dateEditDesc"
|
||||
4 -> _ordering = "dateCreateAsc"
|
||||
5 -> _ordering = "dateCreateDesc"
|
||||
6 -> _ordering = "datePlayAsc"
|
||||
7 -> _ordering = "datePlayDesc"
|
||||
else -> _ordering = null
|
||||
0 -> _ordering.setAndSave("nameAsc")
|
||||
1 -> _ordering.setAndSave("nameDesc")
|
||||
2 -> _ordering.setAndSave("dateEditAsc")
|
||||
3 -> _ordering.setAndSave("dateEditDesc")
|
||||
4 -> _ordering.setAndSave("dateCreateAsc")
|
||||
5 -> _ordering.setAndSave("dateCreateDesc")
|
||||
6 -> _ordering.setAndSave("datePlayAsc")
|
||||
7 -> _ordering.setAndSave("datePlayDesc")
|
||||
else -> _ordering.setAndSave("")
|
||||
}
|
||||
updatePlaylistsFiltering()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
};
|
||||
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
|
||||
|
||||
|
||||
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
|
||||
@@ -214,8 +217,8 @@ class PlaylistsFragment : MainFragment() {
|
||||
var playlistsToReturn = pls;
|
||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||
if(!_ordering.isNullOrEmpty()){
|
||||
playlistsToReturn = when(_ordering){
|
||||
if(!_ordering.value.isNullOrEmpty()){
|
||||
playlistsToReturn = when(_ordering.value){
|
||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
|
||||
|
||||
+24
-17
@@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.exceptions.RateLimitException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
@@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
private var _group: SubscriptionGroup? = null;
|
||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||
|
||||
private val _filterLock = Object();
|
||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_view?.onShown();
|
||||
@@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
}
|
||||
private val _filterLock = Object();
|
||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
||||
|
||||
private var _bypassRateLimit = false;
|
||||
private val _lastExceptions: List<Throwable>? = null;
|
||||
@@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
fragment.navigate<SubscriptionGroupFragment>(g);
|
||||
};
|
||||
|
||||
synchronized(_filterLock) {
|
||||
synchronized(fragment._filterLock) {
|
||||
_subscriptionBar?.setToggles(
|
||||
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
|
||||
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
|
||||
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
|
||||
toggleFilterContentType(ContentType.POST, active); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active ->
|
||||
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
|
||||
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
|
||||
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
toggleFilterContentType(contentType, isTrue);
|
||||
}
|
||||
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
||||
synchronized(_filterLock) {
|
||||
synchronized(fragment._filterLock) {
|
||||
if(!isTrue) {
|
||||
_filterSettings.allowContentTypes.remove(contentType);
|
||||
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
|
||||
_filterSettings.allowContentTypes.add(contentType)
|
||||
fragment._filterSettings.allowContentTypes.remove(contentType);
|
||||
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
|
||||
fragment._filterSettings.allowContentTypes.add(contentType)
|
||||
}
|
||||
_filterSettings.save();
|
||||
fragment._filterSettings.save();
|
||||
};
|
||||
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
||||
loadResults(false);
|
||||
@@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
||||
val filterGroup = subGroup;
|
||||
return results.filter {
|
||||
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||
|
||||
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
||||
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
||||
return@filter false;
|
||||
|
||||
//TODO: Check against a sub cache
|
||||
@@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
|
||||
if(it.datetime?.isAfter(nowSoon) == true) {
|
||||
if(!_filterSettings.allowPlanned)
|
||||
if(!fragment._filterSettings.allowPlanned)
|
||||
return@filter false;
|
||||
}
|
||||
|
||||
if(_filterSettings.allowLive) { //If allowLive, always show live
|
||||
if(fragment._filterSettings.allowLive) { //If allowLive, always show live
|
||||
if(it is IPlatformVideo && it.isLive)
|
||||
return@filter true;
|
||||
}
|
||||
|
||||
+3
@@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onOptions.subscribe {
|
||||
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
|
||||
}
|
||||
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
|
||||
+2
@@ -104,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
|
||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
||||
|
||||
_videoListEditorView = videoListEditorView;
|
||||
@@ -122,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
open fun onShuffleClick() { }
|
||||
open fun onEditClick() { }
|
||||
open fun onVideoRemoved(video: IPlatformVideo) {}
|
||||
open fun onVideoOptions(video: IPlatformVideo) {}
|
||||
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
||||
open fun onVideoClicked(video: IPlatformVideo) {
|
||||
|
||||
|
||||
+3
@@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
|
||||
StatePlaylists.instance.removeFromWatchLater(video, true);
|
||||
}
|
||||
}
|
||||
override fun onVideoOptions(video: IPlatformVideo) {
|
||||
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||
}
|
||||
|
||||
override fun onVideoClicked(video: IPlatformVideo) {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
|
||||
@@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) {
|
||||
|
||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SerializedPlatformContent> {
|
||||
val obj = element.jsonObject["contentType"];
|
||||
val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"];
|
||||
|
||||
//TODO: Remove this temporary fallback..at some point
|
||||
if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null)
|
||||
if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null)
|
||||
return SerializedPlatformVideo.serializer();
|
||||
|
||||
if(obj?.jsonPrimitive?.isString != false) {
|
||||
|
||||
@@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() {
|
||||
return values.toList();
|
||||
}
|
||||
}
|
||||
fun any(): Boolean {
|
||||
synchronized(values) {
|
||||
return values.any();
|
||||
}
|
||||
}
|
||||
fun contains(v: String): Boolean {
|
||||
synchronized(values) {
|
||||
return values.contains(v);
|
||||
}
|
||||
}
|
||||
fun indexOf(v: String): Int {
|
||||
synchronized(values){
|
||||
return values.indexOf(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-45
@@ -15,12 +15,14 @@ import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
||||
import com.futo.platformplayer.debug.Stopwatch
|
||||
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.fragment.mainactivity.main.SubscriptionsFeedFragment
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -32,6 +34,8 @@ import com.futo.platformplayer.subsexchange.ChannelRequest
|
||||
import com.futo.platformplayer.subsexchange.ChannelResolve
|
||||
import com.futo.platformplayer.subsexchange.ExchangeContract
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
@@ -82,23 +86,30 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
var providedTasks: MutableList<SubscriptionTask>? = null;
|
||||
|
||||
try {
|
||||
val contractableTasks =
|
||||
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
|
||||
contract =
|
||||
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
|
||||
ChannelRequest(it.url)
|
||||
}.toTypedArray()) else null;
|
||||
if (contract?.provided?.isNotEmpty() == true)
|
||||
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
|
||||
if (contract != null && contract.required.isNotEmpty()) {
|
||||
providedTasks = mutableListOf()
|
||||
for (task in tasks.toList()) {
|
||||
if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
|
||||
providedTasks.add(task);
|
||||
tasks.remove(task);
|
||||
val contractingTime = measureTimeMillis {
|
||||
val contractableTasks =
|
||||
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
|
||||
contract =
|
||||
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
|
||||
ChannelRequest(it.url)
|
||||
}.toTypedArray()) else null;
|
||||
if (contract?.provided?.isNotEmpty() == true)
|
||||
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
|
||||
if (contract != null && contract!!.required.isNotEmpty()) {
|
||||
providedTasks = mutableListOf()
|
||||
for (task in tasks.toList()) {
|
||||
if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) {
|
||||
providedTasks!!.add(task);
|
||||
tasks.remove(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(contract != null)
|
||||
Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms");
|
||||
else if(contractingTime > 100)
|
||||
Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms");
|
||||
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex);
|
||||
@@ -109,6 +120,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
|
||||
|
||||
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
||||
var resolveCount = 0;
|
||||
var resolveTime = 0L;
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
@@ -137,50 +150,82 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//Resolve Subscription Exchange
|
||||
if(contract != null) {
|
||||
try {
|
||||
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map {
|
||||
ChannelResolve(
|
||||
it.task.url,
|
||||
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
|
||||
)
|
||||
}.toTypedArray()
|
||||
val resolve = subsExchangeClient?.resolveContract(
|
||||
contract,
|
||||
*resolves
|
||||
);
|
||||
if (resolve != null) {
|
||||
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
|
||||
for(result in resolve){
|
||||
val task = providedTasks?.find { it.url == result.channelUrl };
|
||||
if(task != null) {
|
||||
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
|
||||
providedTasks?.remove(task);
|
||||
//Resolve Subscription Exchange
|
||||
if(contract != null) {
|
||||
fun resolve() {
|
||||
try {
|
||||
resolveTime = measureTimeMillis {
|
||||
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map {
|
||||
ChannelResolve(
|
||||
it.task.url,
|
||||
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
|
||||
)
|
||||
}.toTypedArray()
|
||||
|
||||
val resolveRequestStart = OffsetDateTime.now();
|
||||
|
||||
val resolve = subsExchangeClient?.resolveContract(
|
||||
contract!!,
|
||||
*resolves
|
||||
);
|
||||
|
||||
Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms");
|
||||
|
||||
if (resolve != null) {
|
||||
resolveCount = resolves.size;
|
||||
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}")
|
||||
for(result in resolve){
|
||||
val task = providedTasks?.find { it.url == result.channelUrl };
|
||||
if(task != null) {
|
||||
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
|
||||
providedTasks?.remove(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providedTasks != null) {
|
||||
for(task in providedTasks!!) {
|
||||
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms");
|
||||
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
//TODO: fetch remainder after all?
|
||||
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
if (providedTasks != null) {
|
||||
for(task in providedTasks) {
|
||||
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
|
||||
if(providedTasks?.size ?: 0 == 0)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
//TODO: fetch remainder after all?
|
||||
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
|
||||
else
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms");
|
||||
if(resolveCount > 0) {
|
||||
val selfFetchTime = timeTotal - resolveTime;
|
||||
val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache };
|
||||
if(selfFetchCount > 0) {
|
||||
val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount;
|
||||
val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage;
|
||||
val selfFetchDelta = timeTotal - estimateSelfFetchTime;
|
||||
if(selfFetchDelta > 0)
|
||||
UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true);
|
||||
else
|
||||
UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true);
|
||||
}
|
||||
}
|
||||
|
||||
//Cache pagers grouped by channel
|
||||
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
||||
.map { entry ->
|
||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache };
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null };
|
||||
val cachedTasks = entry.value.filter { it.task.fromCache };
|
||||
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
|
||||
onNewCacheHit.emit(sub!!, it);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.OffsetDateTime
|
||||
@@ -12,12 +13,12 @@ import java.time.OffsetDateTime
|
||||
@Serializable
|
||||
class ChannelResult(
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
@SerialName("DateTime")
|
||||
@SerialName("dateTime")
|
||||
var dateTime: OffsetDateTime,
|
||||
@SerialName("ChannelUrl")
|
||||
@SerialName("channelUrl")
|
||||
var channelUrl: String,
|
||||
@SerialName("Content")
|
||||
@SerialName("content")
|
||||
var content: List<SerializedPlatformContent>,
|
||||
@SerialName("Channel")
|
||||
@SerialName("channel")
|
||||
var channel: IPlatformChannel? = null
|
||||
)
|
||||
@@ -1,10 +1,14 @@
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG
|
||||
import com.futo.platformplayer.subsexchange.ChannelRequest
|
||||
import com.futo.platformplayer.subsexchange.ChannelResolve
|
||||
import com.futo.platformplayer.subsexchange.ChannelResult
|
||||
import com.futo.platformplayer.subsexchange.ExchangeContract
|
||||
import com.futo.platformplayer.subsexchange.ExchangeContractResolve
|
||||
import com.futo.platformplayer.toGzip
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -26,9 +30,10 @@ import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
|
||||
class SubsExchangeClient(private val server: String, private val privateKey: String) {
|
||||
class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -40,23 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
|
||||
|
||||
// Endpoint: Contract
|
||||
fun requestContract(vararg channels: ChannelRequest): ExchangeContract {
|
||||
val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
|
||||
val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout)
|
||||
return Json.decodeFromString(data)
|
||||
}
|
||||
suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract {
|
||||
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
|
||||
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json")
|
||||
return Json.decodeFromString(data)
|
||||
}
|
||||
|
||||
// Endpoint: Resolve
|
||||
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
||||
val contractResolve = convertResolves(*resolves)
|
||||
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
|
||||
val contractResolveJson = Serializer.json.encodeToString(contractResolve);
|
||||
val contractResolveTimeStart = OffsetDateTime.now();
|
||||
val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true)
|
||||
val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds();
|
||||
Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result);
|
||||
return Serializer.json.decodeFromString(result)
|
||||
}
|
||||
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
||||
val contractResolve = convertResolves(*resolves)
|
||||
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
|
||||
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true)
|
||||
return Serializer.json.decodeFromString(result)
|
||||
}
|
||||
|
||||
@@ -73,13 +82,24 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
|
||||
}
|
||||
|
||||
// IO methods
|
||||
private fun post(query: String, body: String, contentType: String): String {
|
||||
private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String {
|
||||
val url = URL("${server.trim('/')}$query")
|
||||
with(url.openConnection() as HttpURLConnection) {
|
||||
if(timeout > 0)
|
||||
this.connectTimeout = timeout
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", contentType)
|
||||
doOutput = true
|
||||
OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() }
|
||||
|
||||
|
||||
if(gzip) {
|
||||
val gzipData = body.toGzip();
|
||||
setRequestProperty("Content-Encoding", "gzip");
|
||||
outputStream.write(gzipData);
|
||||
Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}");
|
||||
}
|
||||
else
|
||||
outputStream.write(body);
|
||||
|
||||
val status = responseCode;
|
||||
Logger.i("SubsExchangeClient", "POST [${url}]: ${status}");
|
||||
@@ -102,9 +122,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun postAsync(query: String, body: String, contentType: String): String {
|
||||
private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
post(query, body, contentType)
|
||||
post(query, body, contentType, 0, gzip)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
@@ -46,8 +47,13 @@ class ToggleBar : LinearLayout {
|
||||
_tagsContainer.removeAllViews();
|
||||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
this.setInfo(button.name, button.isActive);
|
||||
this.onClick.subscribe { button.action(it); };
|
||||
if(button.icon > 0)
|
||||
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
|
||||
else if(button.iconVariable != null)
|
||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
||||
else
|
||||
this.setInfo(button.name, button.isActive, button.isButton);
|
||||
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -55,20 +61,42 @@ class ToggleBar : LinearLayout {
|
||||
class Toggle {
|
||||
val name: String;
|
||||
val icon: Int;
|
||||
val action: (Boolean)->Unit;
|
||||
val iconVariable: ImageVariable?;
|
||||
val action: (ToggleTagView, Boolean)->Unit;
|
||||
val isActive: Boolean;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
var tag: String? = null;
|
||||
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = icon;
|
||||
this.icon = 0;
|
||||
this.iconVariable = icon;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.icon = icon;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
fun asButton(): Toggle{
|
||||
isButton = true;
|
||||
return this;
|
||||
}
|
||||
fun withTag(str: String): Toggle {
|
||||
tag = str;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
||||
|
||||
val onClick = Event1<IPlatformVideo>();
|
||||
val onRemove = Event1<IPlatformVideo>();
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
var canEdit = false
|
||||
private set;
|
||||
|
||||
@@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
||||
val holder = VideoListEditorViewHolder(view, _touchHelper);
|
||||
|
||||
holder.onRemove.subscribe { v -> onRemove.emit(v); };
|
||||
holder.onOptions.subscribe { v -> onOptions.emit(v); };
|
||||
holder.onClick.subscribe { v -> onClick.emit(v); };
|
||||
|
||||
return holder;
|
||||
|
||||
@@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
private val _containerDuration: LinearLayout;
|
||||
private val _containerLive: LinearLayout;
|
||||
private val _imageRemove: ImageButton;
|
||||
private val _imageOptions: ImageButton;
|
||||
private val _imageDragDrop: ImageButton;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _layoutDownloaded: FrameLayout;
|
||||
@@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
|
||||
val onClick = Event1<IPlatformVideo>();
|
||||
val onRemove = Event1<IPlatformVideo>();
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
|
||||
@@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
|
||||
_containerLive = view.findViewById(R.id.thumbnail_live_container);
|
||||
_imageRemove = view.findViewById(R.id.image_trash);
|
||||
_imageOptions = view.findViewById(R.id.image_settings);
|
||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||
@@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
val v = video ?: return@setOnClickListener;
|
||||
onRemove.emit(v);
|
||||
};
|
||||
_imageOptions?.setOnClickListener {
|
||||
val v = video ?: return@setOnClickListener;
|
||||
onOptions.emit(v);
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout {
|
||||
|
||||
val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
|
||||
val onVideoRemoved = Event1<IPlatformVideo>();
|
||||
val onVideoOptions = Event1<IPlatformVideo>();
|
||||
val onVideoClicked = Event1<IPlatformVideo>();
|
||||
val isEmpty get() = _videos.isEmpty();
|
||||
|
||||
@@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout {
|
||||
}
|
||||
};
|
||||
|
||||
adapterVideos.onOptions.subscribe { v ->
|
||||
onVideoOptions?.emit(v);
|
||||
}
|
||||
adapterVideos.onRemove.subscribe { v ->
|
||||
val executeDelete = {
|
||||
synchronized(_videos) {
|
||||
|
||||
@@ -4,27 +4,42 @@ import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.images.GlideHelper
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
|
||||
class ToggleTagView : LinearLayout {
|
||||
private val _root: FrameLayout;
|
||||
private val _textTag: TextView;
|
||||
private var _text: String = "";
|
||||
private var _image: ImageView;
|
||||
|
||||
var isActive: Boolean = false
|
||||
private set;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
|
||||
var onClick = Event1<Boolean>();
|
||||
var onClick = Event2<ToggleTagView, Boolean>();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
|
||||
_root = findViewById(R.id.root);
|
||||
_textTag = findViewById(R.id.text_tag);
|
||||
_root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); }
|
||||
_image = findViewById(R.id.image_tag);
|
||||
_root.setOnClickListener {
|
||||
if(!isButton)
|
||||
setToggle(!isActive);
|
||||
onClick.emit(this, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
fun setToggle(isActive: Boolean) {
|
||||
@@ -39,9 +54,48 @@ class ToggleTagView : LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fun setInfo(text: String, isActive: Boolean) {
|
||||
fun setInfo(toggle: ToggleBar.Toggle){
|
||||
_text = toggle.name;
|
||||
_textTag.text = toggle.name;
|
||||
setToggle(toggle.isActive);
|
||||
if(toggle.iconVariable != null) {
|
||||
toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred);
|
||||
_image.visibility = View.GONE;
|
||||
}
|
||||
else if(toggle.icon > 0) {
|
||||
_image.setImageResource(toggle.icon);
|
||||
_image.visibility = View.GONE;
|
||||
}
|
||||
else
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
|
||||
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
_image.setImageResource(imageResource);
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
image.setImageView(_image, R.drawable.ic_error_pred);
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_image.visibility = View.GONE;
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
setToggle(isActive);
|
||||
this.isButton = isButton;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import android.widget.LinearLayout
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
@@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout {
|
||||
private val _overlayContainer: FrameLayout;
|
||||
|
||||
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
val onClose = Event0();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
@@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout {
|
||||
|
||||
_topbar.onClose.subscribe(this, onClose::emit);
|
||||
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
|
||||
_editor.onVideoOptions.subscribe { v ->
|
||||
onOptions?.emit(v);
|
||||
}
|
||||
_editor.onVideoRemoved.subscribe { v ->
|
||||
StatePlayer.instance.removeFromQueue(v);
|
||||
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));
|
||||
|
||||
@@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
_textOK.visibility = View.VISIBLE;
|
||||
}
|
||||
}
|
||||
fun getSlideUpItemByTag(itemTag: Any?): SlideUpMenuItem? {
|
||||
for(view in groupItems){
|
||||
if(view is SlideUpMenuItem && view.itemTag == itemTag)
|
||||
return view;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean {
|
||||
var didSelect = false;
|
||||
|
||||
@@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
|
||||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
this.setInfo(button.name, button.isActive);
|
||||
this.onClick.subscribe { button.action(it); };
|
||||
this.onClick.subscribe({ view, value -> button.action(view, value); });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout {
|
||||
class Toggle {
|
||||
val name: String;
|
||||
val icon: Int;
|
||||
val action: (Boolean)->Unit;
|
||||
val action: (ToggleTagView, Boolean)->Unit;
|
||||
val isActive: Boolean;
|
||||
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = icon;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.action = action;
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_author"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_video_name"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
@@ -169,19 +169,35 @@
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_author"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/image_trash"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_delete"
|
||||
app:srcCompat="@drawable/ic_trash_18dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
<LinearLayout
|
||||
android:id="@+id/buttons"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
|
||||
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" >
|
||||
<ImageButton
|
||||
android:id="@+id/image_trash"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_delete"
|
||||
app:srcCompat="@drawable/ic_trash_18dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"/>
|
||||
<ImageButton
|
||||
android:id="@+id/image_settings"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_settings"
|
||||
app:srcCompat="@drawable/ic_settings"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -3,14 +3,14 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ScrollView
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="horizontal">
|
||||
<LinearLayout
|
||||
android:id="@+id/container_tags"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</ScrollView>
|
||||
</HorizontalScrollView>
|
||||
</LinearLayout>
|
||||
@@ -3,23 +3,37 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:paddingStart="15dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginTop="17dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:id="@+id/root">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_tag"
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textSize="11dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
tools:text="Tag text" />
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
<ImageView
|
||||
android:id="@+id/image_tag"
|
||||
android:visibility="gone"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginLeft="2.5dp"
|
||||
android:layout_marginRight="2.5dp" />
|
||||
<TextView
|
||||
android:id="@+id/text_tag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="2.5dp"
|
||||
android:layout_marginRight="2.5dp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textSize="11dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
tools:text="Tag text" />
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -417,6 +417,10 @@
|
||||
<string name="show_subscription_group_description">If subscription groups should be shown above your subscriptions to filter</string>
|
||||
<string name="preview_feed_items">Preview Feed Items</string>
|
||||
<string name="preview_feed_items_description">When the preview feedstyle is used, if items should auto-preview when scrolling over them</string>
|
||||
<string name="show_home_filters">Show Home Filters</string>
|
||||
<string name="show_home_filters_description">If the home filters should be shown above home</string>
|
||||
<string name="show_home_filters_plugin_names">Home filter Plugin Names</string>
|
||||
<string name="show_home_filters_plugin_names_description">If home filters should show full plugin names or just icons</string>
|
||||
<string name="log_level">Log Level</string>
|
||||
<string name="logging">Logging</string>
|
||||
<string name="sync_grayjay">Sync Grayjay</string>
|
||||
|
||||
Submodule app/src/stable/assets/sources/odysee updated: f2f83344eb...215cd9bd70
Submodule app/src/stable/assets/sources/soundcloud updated: bff981c3ce...f8234d6af8
Submodule app/src/stable/assets/sources/spotify updated: 331dd92929...b61095ec20
Submodule app/src/stable/assets/sources/youtube updated: ae7b62f4d8...6f1266a038
Submodule app/src/unstable/assets/sources/odysee updated: f2f83344eb...215cd9bd70
Submodule app/src/unstable/assets/sources/soundcloud updated: bff981c3ce...f8234d6af8
Submodule app/src/unstable/assets/sources/spotify updated: 331dd92929...b61095ec20
Submodule app/src/unstable/assets/sources/youtube updated: ae7b62f4d8...6f1266a038
Reference in New Issue
Block a user