Compare commits

..

18 Commits

Author SHA1 Message Date
Kelvin be2067067b Year rounding 2024-03-06 21:59:55 +01:00
Kelvin 67a7dd9698 Refs 2024-03-06 21:44:48 +01:00
Kelvin 6ffc067b24 Support for cache in reconstructions, non-required cache added to exports, playlists shares now add a cache aswell for quicker importing 2024-03-06 21:39:30 +01:00
Kelvin 56e6314c11 Ref 2024-03-05 17:15:36 +01:00
Kelvin e590bb4a19 Fix 0 year issue 2024-03-05 00:05:46 +01:00
Kelvin 35fe7f0e7a Add type to unknown content exception 2024-03-01 15:31:30 +01:00
Koen 45d818ac81 Reverted dependencies. 2024-02-16 15:51:59 +01:00
Kelvin 7729681829 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-02-16 14:58:28 +01:00
Kelvin b12d04b27d Attempted fix for double controls 2024-02-16 14:58:17 +01:00
Koen e6608b9a5c Updated PolycentricAndroid. 2024-02-16 14:07:27 +01:00
Koen 2d503dfaf6 Added scroll to top. Full scrollable parent comment and Polycentric process secret backup and automatic database recovery. 2024-02-16 13:56:14 +01:00
Kelvin 08934ef8de Modify subscription groups in sub settings 2024-02-14 23:25:58 +01:00
Kelvin 62d927739a Sharing from overview options, notification channel names 2024-02-14 20:15:12 +01:00
Kelvin c8db8f58e8 Refs 2024-02-14 19:19:24 +01:00
Kelvin 0fc966a77d Subscription watched filter 2024-02-14 19:18:35 +01:00
Kelvin 9f6c6c8cf3 Fix support, fix membership urls 2024-01-23 23:51:21 +01:00
Kelvin 43a6ff138c Fix queue looping 2024-01-22 20:54:40 +01:00
Kelvin 269a3460e7 Fix live stream retrying 2024-01-22 15:52:51 +01:00
45 changed files with 588 additions and 238 deletions
+2 -4
View File
@@ -243,9 +243,7 @@ declare class DashSource implements IVideoSource {
declare interface IRequest {
url: string,
headers: Map<string, string>,
method: string,
body?: string
headers: Map<string, string>
}
declare interface IRequestModifierDef {
allowByteSkip: boolean
@@ -253,7 +251,7 @@ declare interface IRequestModifierDef {
declare class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>, method: string, body?: string): IRequest;
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
//Channel
@@ -1,4 +1,4 @@
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
@@ -13,6 +13,8 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
//Long
@@ -119,7 +121,8 @@ fun OffsetDateTime.getNowDiffMonths(): Long {
return ChronoUnit.MONTHS.between(this, OffsetDateTime.now());
}
fun OffsetDateTime.getNowDiffYears(): Long {
return ChronoUnit.YEARS.between(this, OffsetDateTime.now());
val diff = ChronoUnit.MONTHS.between(this, OffsetDateTime.now()) / 12.0;
return diff.roundToLong();
}
fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long {
@@ -150,6 +153,7 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
if(value >= secondsInYear) {
value = getNowDiffYears();
if(abs) value = abs(value);
value = Math.max(1, value);
unit = "year";
}
else if(value >= secondsInMonth) {
@@ -34,6 +34,7 @@ import com.futo.platformplayer.dialogs.MigrateDialog
import com.futo.platformplayer.dialogs.ProgressDialog
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
@@ -343,8 +344,8 @@ class UIDialogs {
}
}
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, onConcluded: () -> Unit) {
val dialog = ImportDialog(context, store, name, reconstructions, onConcluded);
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, cache: ImportCache?, onConcluded: () -> Unit) {
val dialog = ImportDialog(context, store, name, reconstructions, cache, onConcluded);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -37,11 +38,17 @@ import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuRecycler
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup
@@ -87,7 +94,37 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
-1, listOf()) else null,
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuRecycler(container.context, "as") {
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if(it is SubscriptionGroup.Selectable) {
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
?: return@subscribe;
groups.clear();
if(it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
}
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
@@ -646,9 +683,17 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
container.context.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain";
}, null));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
@@ -41,6 +41,7 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.listeners.OrientationManager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage
@@ -603,7 +604,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]",
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
"Ok",
{ });
}
@@ -693,10 +694,22 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(!recon.trim().startsWith("["))
return handleUnknownJson(recon);
val reconLines = Json.decodeFromString<List<String>>(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n");
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon);
handleReconstruction(recon, cache);
return true;
}
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
@@ -711,12 +724,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun handleFile(file: String): Boolean {
Logger.i(TAG, "handleFile(url=$file)");
if(file.lowercase().endsWith(".json")) {
val recon = String(readSharedFile(file));
var recon = String(readSharedFile(file));
if(!recon.startsWith("["))
return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n");
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon);
handleReconstruction(recon, cache);
return true;
}
else if(file.lowercase().endsWith(".zip")) {
@@ -728,7 +754,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
return false;
}
fun handleReconstruction(recon: String) {
fun handleReconstruction(recon: String, cache: ImportCache? = null) {
val type = ManagedStore.getReconstructionIdentifier(recon);
val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore
@@ -745,7 +771,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(!type.isNullOrEmpty()) {
UIDialogs.showImportDialog(this, store, name, listOf(recon)) {
UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
}
}
@@ -12,6 +12,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
@@ -70,6 +71,12 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle = ProcessHandle.create();
Store.instance.addProcessSecret(processHandle.processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
processHandle.addServer("https://srv1-stg.polycentric.io");
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
@@ -13,6 +13,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
@@ -126,6 +127,12 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) {
@@ -37,6 +37,10 @@ class SerializedChannel(
TODO("Not yet implemented")
}
fun isSameUrl(url: String): Boolean {
return this.url == url || urlAlternatives.contains(url);
}
companion object {
fun fromChannel(channel: IPlatformChannel): SerializedChannel {
return SerializedChannel(
@@ -1,14 +1,14 @@
package com.futo.platformplayer.api.media.models.modifier
class AdhocRequestModifier: IRequestModifier {
val _handler: (String, Map<String,String>, String, String?)->IRequest;
val _handler: (String, Map<String,String>)->IRequest;
override var allowByteSkip: Boolean = false;
constructor(modifyReq: (String, Map<String,String>, String, String?)->IRequest) {
constructor(modifyReq: (String, Map<String,String>)->IRequest) {
_handler = modifyReq;
}
override fun modifyRequest(url: String, headers: Map<String, String>, method: String, body: String?): IRequest {
return _handler(url, headers, method, body);
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
return _handler(url, headers);
}
}
@@ -1,8 +1,6 @@
package com.futo.platformplayer.api.media.models.modifier
interface IRequest {
val method: String?;
val url: String?;
val headers: Map<String, String>?;
val body: String?;
val headers: Map<String, String>;
}
@@ -3,5 +3,5 @@ package com.futo.platformplayer.api.media.models.modifier
interface IRequestModifier {
var allowByteSkip: Boolean;
fun modifyRequest(url: String, headers: Map<String, String>, method: String, body: String?): IRequest
fun modifyRequest(url: String, headers: Map<String, String>): IRequest
}
@@ -12,40 +12,31 @@ import com.futo.platformplayer.getOrDefault
class JSRequest : IRequest {
private val _v8Url: String?;
private val _v8Headers: Map<String, String>?;
private val _v8Method: String?;
private val _v8Body: String?;
private val _v8Options: Options?;
override var url: String? = null;
override lateinit var headers: Map<String, String>;
override var method: String? = null;
override var body: String? = null;
constructor(plugin: JSClient, url: String?, headers: Map<String, String>?, method: String?, body: String?, options: Options?, originalUrl: String?, originalHeaders: Map<String, String>?, originalMethod: String?, originalBody: String?) {
constructor(plugin: JSClient, url: String?, headers: Map<String, String>?, options: Options?, originalUrl: String?, originalHeaders: Map<String, String>?) {
_v8Url = url;
_v8Headers = headers;
_v8Method = method;
_v8Body = body;
_v8Options = options;
initialize(plugin, originalUrl, originalHeaders, originalMethod, originalBody);
initialize(plugin, originalUrl, originalHeaders);
}
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, originalMethod: String?, originalBody: String?, applyOtherHeadersByDefault: Boolean = false) {
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
val contextName = "ModifyRequestResponse";
val config = plugin.config;
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
_v8Method = obj.getOrDefault<String>(config, "method", contextName, null);
_v8Body = obj.getOrDefault<String>(config, "body", contextName, null);
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
Options(config, it, applyOtherHeadersByDefault);
} ?: Options(null, null, applyOtherHeadersByDefault);
initialize(plugin, originalUrl, originalHeaders, originalMethod, originalBody);
initialize(plugin, originalUrl, originalHeaders);
}
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?, originalMethod: String?, originalBody: String?) {
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
val config = plugin.config;
url = _v8Url ?: originalUrl;
method = _v8Method ?: originalMethod;
body = _v8Body ?: originalBody;
if(_v8Options?.applyOtherHeaders ?: false) {
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
@@ -78,8 +69,8 @@ class JSRequest : IRequest {
}
}
fun modify(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?, originalMethod: String?, originalBody: String?): JSRequest {
return JSRequest(plugin, _v8Url, _v8Headers, _v8Method, _v8Body, _v8Options, originalUrl, originalHeaders, originalMethod, originalBody);
fun modify(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?): JSRequest {
return JSRequest(plugin, _v8Url, _v8Headers, _v8Options, originalUrl, originalHeaders);
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media.platforms.js.models
import android.net.Uri
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.modifier.IRequest
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
@@ -7,7 +8,9 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
class JSRequestModifier: IRequestModifier {
private val _plugin: JSClient;
@@ -27,20 +30,20 @@ class JSRequestModifier: IRequestModifier {
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
}
override fun modifyRequest(url: String, headers: Map<String, String>, method: String, body: String?): IRequest {
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
if (_modifier.isClosed) {
return Request(url, headers, method, body);
return Request(url, headers);
}
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invoke("modifyRequest", url, headers, method, body);
_modifier.invoke("modifyRequest", url, headers);
} as V8ValueObject;
val req = JSRequest(_plugin, result, url, headers, method, body);
val req = JSRequest(_plugin, result, url, headers);
result.close();
return req;
}
data class Request(override val url: String, override val headers: Map<String, String>, override val method: String, override val body: String?) : IRequest;
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
}
@@ -1,5 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier
@@ -13,6 +15,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.orNull
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
abstract class JSSource {
protected val _plugin: JSClient;
@@ -30,15 +33,15 @@ abstract class JSSource {
this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, null, null, true);
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
}
fun getRequestModifier(): IRequestModifier? {
if(_requestModifier != null)
return AdhocRequestModifier { url, headers, method, body ->
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers, method, body);
return AdhocRequestModifier { url, headers ->
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
};
if (!hasRequestModifier || _obj.isClosed) {
@@ -22,7 +22,9 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.assume
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -66,13 +68,15 @@ class ImportDialog : AlertDialog {
private val _name: String;
private val _toImport: List<String>;
private val _cache: ImportCache?;
constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, onConcluded: ()->Unit): super(context) {
constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, cache: ImportCache?, onConcluded: ()->Unit): super(context) {
_context = context;
_store = importStore;
_onConcluded = onConcluded;
_name = name;
_toImport = ArrayList(toReconstruct);
_cache = cache;
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -146,7 +150,7 @@ class ImportDialog : AlertDialog {
val scope = StateApp.instance.scopeOrNull;
scope?.launch(Dispatchers.IO) {
try {
val migrationResult = _store.importReconstructions(_toImport) { finished, total ->
val migrationResult = _store.importReconstructions(_toImport, _cache) { finished, total ->
scope.launch(Dispatchers.Main) {
_textProgress.text = "${finished}/${total}";
}
@@ -25,6 +25,7 @@ import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
@@ -197,6 +198,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST);
var allowLive: Boolean = true;
var allowPlanned: Boolean = false;
var allowWatched: Boolean = true;
override fun encode(): String {
return Json.encodeToString(this);
}
@@ -304,7 +306,8 @@ class SubscriptionsFeedFragment : MainFragment() {
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.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); }
);
}
@@ -336,6 +339,9 @@ class SubscriptionsFeedFragment : MainFragment() {
return results.filter {
val allowedContentType = _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))
return@filter false;
//TODO: Check against a sub cache
if(filterGroup != null && !filterGroup.urls.contains(it.author.url))
return@filter false;
@@ -398,6 +398,10 @@ class VideoDetailView : ConstraintLayout {
}
}
};
_monetization.onUrlTap.subscribe {
fragment.navigate<BrowserFragment>(it);
onMinimize.emit();
}
_player.attachPlayer();
@@ -1035,10 +1039,10 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main);
}
fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0) {
fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0, bypassSameVideoCheck: Boolean = false) {
Logger.i(TAG, "setVideoOverview")
if(this.video?.url == video.url)
if(!bypassSameVideoCheck && this.video?.url == video.url)
return;
val cachedVideo = StateDownloads.instance.getCachedVideo(video.id);
@@ -1663,7 +1667,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "prevVideo")
val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) {
setVideoOverview(next);
setVideoOverview(next, true, 0, true);
}
}
@@ -1673,7 +1677,7 @@ class VideoDetailView : ConstraintLayout {
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
if(next != null) {
setVideoOverview(next);
setVideoOverview(next, true, 0, true);
return true;
}
else
@@ -2562,7 +2566,7 @@ class VideoDetailView : ConstraintLayout {
}
else
withContext(Dispatchers.Main) {
setVideoDetails(videoDetail, true);
setVideoDetails(videoDetail, false);
_liveTryJob = null;
}
}
@@ -30,7 +30,7 @@ class HistoryVideo {
}
companion object {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo?)? = null): HistoryVideo {
var index = str.indexOf("|||");
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
val url = str.substring(0, index);
@@ -0,0 +1,11 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import kotlinx.serialization.Serializable
@Serializable
class ImportCache(
var videos: List<SerializedPlatformVideo>? = null,
var channels: List<SerializedChannel>? = null
);
@@ -0,0 +1,42 @@
package com.futo.platformplayer.polycentric
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.polycentric.core.ProcessSecret
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import userpackage.Protocol
class PolycentricStorage {
private val _processSecrets = FragmentedStorage.get<StringArrayStorage>("processSecrets");
fun addProcessSecret(processSecret: ProcessSecret) {
_processSecrets.addDistinct(GEncryptionProviderV1.instance.encrypt(processSecret.toProto().toByteArray()).toBase64())
_processSecrets.saveBlocking()
}
fun getProcessSecrets(): List<ProcessSecret> {
val processSecrets = arrayListOf<ProcessSecret>()
for (p in _processSecrets.getAllValues()) {
try {
processSecrets.add(ProcessSecret.fromProto(Protocol.StorageTypeProcessSecret.parseFrom(GEncryptionProviderV1.instance.decrypt(p.base64ToByteArray()))))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to decrypt process secret", e);
}
}
return processSecrets
}
companion object {
val TAG = "PolycentricStorage";
private var _instance : PolycentricStorage? = null;
val instance : PolycentricStorage
get(){
if(_instance == null)
_instance = PolycentricStorage();
return _instance!!;
};
}
}
@@ -37,6 +37,7 @@ class DownloadService : Service() {
private val DOWNLOAD_NOTIF_ID = 3;
private val DOWNLOAD_NOTIF_TAG = "download";
private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel";
private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads";
//Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -95,7 +96,7 @@ class DownloadService : Service() {
}
fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply {
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false);
this.setSound(null, null);
};
@@ -36,6 +36,7 @@ class ExportingService : Service() {
private val EXPORT_NOTIF_ID = 4;
private val EXPORT_NOTIF_TAG = "export";
private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel";
private val EXPORT_NOTIF_CHANNEL_NAME = "Export";
//Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -88,7 +89,7 @@ class ExportingService : Service() {
}
fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply {
_notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false);
this.setSound(null, null);
};
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
@@ -17,6 +18,7 @@ import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
@@ -58,6 +60,19 @@ class StateBackup {
StatePlaylists.instance.toMigrateCheck()
).flatten();
fun getCache(): ImportCache {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val videos = allPlaylists.flatMap { it.videos }.distinctBy { it.url };
val allSubscriptions = StateSubscriptions.instance.getSubscriptions();
val channels = allSubscriptions.map { it.channel };
return ImportCache(
videos = videos,
channels = channels
);
}
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
@@ -233,11 +248,10 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
val cache = getCache();
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache);
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
//export.videoCache = StatePlaylists.instance.getHistory()
// .distinctBy { it.video.url }
// .map { it.video };
return export;
}
@@ -324,7 +338,7 @@ class StateBackup {
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
@@ -453,8 +467,8 @@ class StateBackup {
val stores: Map<String, List<String>>,
val plugins: Map<String, String>,
val pluginSettings: Map<String, Map<String, String?>>,
var cache: ImportCache? = null
) {
var videoCache: List<SerializedPlatformVideo>? = null;
fun asZip(): ByteArray {
return ByteArrayOutputStream().use { byteStream ->
@@ -478,6 +492,17 @@ class StateBackup {
zipStream.putNextEntry(ZipEntry("plugin_settings"));
zipStream.write(Json.encodeToString(pluginSettings).toByteArray());
if(cache != null) {
if(cache?.videos != null) {
zipStream.putNextEntry(ZipEntry("cache_videos"));
zipStream.write(Json.encodeToString(cache!!.videos).toByteArray());
}
if(cache?.channels != null) {
zipStream.putNextEntry(ZipEntry("cache_channels"));
zipStream.write(Json.encodeToString(cache!!.channels).toByteArray());
}
}
};
return byteStream.toByteArray();
}
@@ -492,6 +517,8 @@ class StateBackup {
val stores: MutableMap<String, List<String>> = mutableMapOf();
var plugins: Map<String, String> = mapOf();
var pluginSettings: Map<String, Map<String, String?>> = mapOf();
var videoCache: List<SerializedPlatformVideo>? = null
var channelCache: List<SerializedChannel>? = null
while (zipStream.nextEntry.also { entry = it } != null) {
if(entry!!.isDirectory)
@@ -503,6 +530,22 @@ class StateBackup {
"settings" -> settings = String(zipStream.readBytes());
"plugins" -> plugins = Json.decodeFromString(String(zipStream.readBytes()));
"plugin_settings" -> pluginSettings = Json.decodeFromString(String(zipStream.readBytes()));
"cache_videos" -> {
try {
videoCache = Json.decodeFromString(String(zipStream.readBytes()));
}
catch(ex: Exception) {
Logger.e(TAG, "Couldn't deserialize video cache", ex);
}
};
"cache_channels" -> {
try {
channelCache = Json.decodeFromString(String(zipStream.readBytes()));
}
catch(ex: Exception) {
Logger.e(TAG, "Couldn't deserialize channel cache", ex);
}
};
}
else
stores[entry!!.name.substring("stores/".length)] = Json.decodeFromString(String(zipStream.readBytes()));
@@ -511,7 +554,10 @@ class StateBackup {
throw IllegalStateException("Failed to parse zip [${entry?.name}] due to ${ex.message}");
}
}
return ExportStructure(exportInfo, settings, stores, plugins, pluginSettings);
return ExportStructure(exportInfo, settings, stores, plugins, pluginSettings, ImportCache(
videos = videoCache,
channels = channelCache
));
}
}
}
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory
@@ -20,8 +21,8 @@ class StateHistory {
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
.withRestore(object: ReconstructStore<HistoryVideo>() {
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
= HistoryVideo.fromReconString(backup, null);
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, cache: ImportCache?): HistoryVideo
= HistoryVideo.fromReconString(backup) { url -> cache?.videos?.find { it.url == url } };
})
.load();
@@ -50,6 +51,9 @@ class StateHistory {
fun getHistoryPosition(url: String): Long {
return historyIndex[url]?.position ?: 0;
}
fun isHistoryWatched(url: String, duration: Long): Boolean {
return getHistoryPosition(url) > duration * 0.7;
}
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long {
@@ -14,6 +14,7 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
@@ -32,8 +33,10 @@ class StatePlaylists {
.withUnique { it.url }
.withRestore(object: ReconstructStore<SerializedPlatformVideo>() {
override fun toReconstruction(obj: SerializedPlatformVideo): String = obj.url;
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): SerializedPlatformVideo
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): SerializedPlatformVideo
= SerializedPlatformVideo.fromVideo(
importCache?.videos?.find { it.url == backup }?.let { Logger.i(TAG, "Reconstruction [${backup}] from cache"); return@let it; } ?:
StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
})
.load();
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order..
@@ -154,7 +157,11 @@ class StatePlaylists {
val reconstruction = playlistStore.getReconstructionString(playlist, true);
val newFile = File(playlistShareDir, playlist.name + ".json");
newFile.writeText(Json.encodeToString(reconstruction.split("\n")), Charsets.UTF_8);
newFile.writeText(Json.encodeToString(reconstruction.split("\n") + listOf(
"__CACHE:" + Json.encodeToString(ImportCache(
videos = playlist.videos.toList()
))
)), Charsets.UTF_8);
return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile);
}
@@ -185,7 +192,7 @@ class StatePlaylists {
items.addAll(obj.videos.map { it.url });
return items.map { it.replace("\n","") }.joinToString("\n");
}
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Playlist {
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Playlist {
val items = backup.split("\n");
if(items.size <= 0) {
throw IllegalStateException("Cannot reconstructor playlist ${id}");
@@ -194,10 +201,17 @@ class StatePlaylists {
val name = items[0];
val videos = items.drop(1).filter { it.isNotEmpty() }.map {
try {
val video = StatePlatform.instance.getContentDetails(it).await();
val videoUrl = it;
val video = importCache?.videos?.find { it.url == videoUrl } ?:
StatePlatform.instance.getContentDetails(it).await();
if (video is IPlatformVideoDetails) {
return@map SerializedPlatformVideo.fromVideo(video);
} else {
}
else if(video is SerializedPlatformVideo) {
Logger.i(TAG, "Reconstruction [${it}] from cache");
return@map video;
}
else {
return@map null
}
}
@@ -23,6 +23,7 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage
@@ -67,28 +68,40 @@ class StatePolycentric {
return
}
try {
val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
for (i in 0 .. 1) {
try {
val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
val activeProcessHandleString = _activeProcessHandle.value;
if (activeProcessHandleString.isNotEmpty()) {
try {
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
} catch (e: Throwable) {
db.upgradeOldSecrets(db.writableDatabase);
val activeProcessHandleString = _activeProcessHandle.value;
if (activeProcessHandleString.isNotEmpty()) {
try {
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
} catch (e: Throwable) {
db.upgradeOldSecrets(db.writableDatabase);
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
Log.i(TAG, "Failed to initialize Polycentric.", e)
}
}
getProcessHandles()
break;
} catch (e: Throwable) {
if (i == 0) {
Logger.i(TAG, "Clearing Polycentric database due to corruption");
val db = SqlLiteDbHelper(context);
db.recreate()
} else {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e)
}
}
} catch (e: Throwable) {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e)
}
}
@@ -103,7 +116,32 @@ class StatePolycentric {
return listOf()
}
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); };
val storeProcessSecrets = Store.instance.getProcessSecrets().toMutableList()
val processSecrets = PolycentricStorage.instance.getProcessSecrets()
for (processSecret in processSecrets)
{
if (!storeProcessSecrets.contains(processSecret)) {
try {
Store.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
for (processSecret in storeProcessSecrets)
{
if (!processSecrets.contains(processSecret)) {
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
return (storeProcessSecrets + processSecrets).distinct().map { it.toProcessHandle() }
}
fun setProcessHandle(processHandle: ProcessHandle?) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.functional.CentralizedFeed
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -38,8 +39,8 @@ class StateSubscriptions {
.withRestore(object: ReconstructStore<Subscription>(){
override fun toReconstruction(obj: Subscription): String =
obj.channel.url;
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription =
Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false)));
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Subscription =
Subscription(importCache?.channels?.find { it.isSameUrl(backup) } ?: SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false)));
}).load();
private val _subscriptionOthers = FragmentedStorage.storeJson<Subscription>("subscriptions_others")
.withUnique { it.channel.url }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.stores.v2
import com.futo.platformplayer.assume
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -105,7 +106,7 @@ class ManagedStore<T>{
_toReconstruct.clear();
}
}
suspend fun importReconstructions(items: List<String>, onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult {
suspend fun importReconstructions(items: List<String>, cache: ImportCache? = null, onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult {
var successes = 0;
val exs = ArrayList<Throwable>();
@@ -120,7 +121,7 @@ class ManagedStore<T>{
for (i in 0 .. 1) {
try {
Logger.i(TAG, "Importing ${logName(recon)}");
val reconId = createFromReconstruction(recon, builder);
val reconId = createFromReconstruction(recon, builder, cache);
successes++;
Logger.i(TAG, "Imported ${logName(reconId)}");
break;
@@ -272,12 +273,12 @@ class ManagedStore<T>{
save(obj, withReconstruction, onlyExisting);
}
suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder): String {
suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder, cache: ImportCache? = null): String {
if(_reconstructStore == null)
throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type");
val id = UUID.randomUUID().toString();
val reconstruct = _reconstructStore!!.toObjectWithHeader(id, reconstruction, builder);
val reconstruct = _reconstructStore!!.toObjectWithHeader(id, reconstruction, builder, cache);
save(reconstruct);
return id;
}
@@ -1,5 +1,7 @@
package com.futo.platformplayer.stores.v2
import com.futo.platformplayer.models.ImportCache
abstract class ReconstructStore<T> {
open val backupOnSave: Boolean = false;
open val backupOnCreate: Boolean = true;
@@ -11,18 +13,18 @@ abstract class ReconstructStore<T> {
}
abstract fun toReconstruction(obj: T): String;
abstract suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): T;
abstract suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache? = null): T;
fun toReconstructionWithHeader(obj: T, fallbackName: String): String {
val identifier = identifierName ?: fallbackName;
return "@/${identifier}\n${toReconstruction(obj)}";
}
suspend fun toObjectWithHeader(id: String, backup: String, builder: Builder): T {
suspend fun toObjectWithHeader(id: String, backup: String, builder: Builder, importCache: ImportCache? = null): T {
if(backup.startsWith("@/") && backup.contains("\n"))
return toObject(id, backup.substring(backup.indexOf("\n") + 1), builder);
return toObject(id, backup.substring(backup.indexOf("\n") + 1), builder, importCache);
else
return toObject(id, backup, builder);
return toObject(id, backup, builder, importCache);
}
@@ -8,11 +8,13 @@ import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.HorizontalSpaceItemDecoration
import com.futo.platformplayer.R
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -61,6 +63,7 @@ class MonetizationView : LinearLayout {
val onSupportTap = Event0();
val onStoreTap = Event0();
val onUrlTap = Event1<String>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_monetization, this);
@@ -70,10 +73,12 @@ class MonetizationView : LinearLayout {
_membershipPlatform = findViewById(R.id.membership_platform);
_buttonMembership.setOnClickListener {
_membershipUrl?.let {
/*
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri;
context.startActivity(intent);
context.startActivity(intent);*/
onUrlTap.emit(it);
}
}
@@ -129,9 +134,18 @@ class MonetizationView : LinearLayout {
_buttonStore.visibility = View.GONE;
}
if(profile.systemState.donationDestinations.isNotEmpty() ||
profile.systemState.membershipUrls.isNotEmpty() ||
profile.systemState.store.isNotEmpty() ||
profile.systemState.promotion.isNotEmpty())
_buttonSupport.isVisible = true;
else
_buttonSupport.isVisible = false;
_root.visibility = View.VISIBLE;
} else {
_root.visibility = View.GONE;
_buttonSupport.isVisible = false;
}
setMerchandise(null);
@@ -10,6 +10,8 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.view.size
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@@ -33,6 +35,13 @@ class SupportView : LinearLayout {
private var _textNoSupportOptionsSet: TextView
private var _polycentricProfile: PolycentricProfile? = null
val hasSupportItems: Boolean get() {
return (_layoutPromotions.isVisible && _buttonPromotion.isVisible) ||
(_layoutMemberships.isVisible && _layoutMembershipEntries.isVisible && _layoutMembershipEntries.size > 0) ||
(_layoutDonation.isVisible && _layoutDonationEntries.isVisible && _layoutDonationEntries.size > 0) ||
_buttonStore.isVisible;
};
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_support, this);
@@ -51,9 +51,11 @@ class RepliesOverlay : LinearLayout {
private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null;
private val _loaderOverlay: LoaderOverlay
private val _client = ManagedHttpClient()
private val _layoutItems: LinearLayout
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_replies, this)
_layoutItems = findViewById(R.id.layout_items)
_topbar = findViewById(R.id.topbar);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
@@ -65,6 +67,9 @@ class RepliesOverlay : LinearLayout {
_loaderOverlay = findViewById(R.id.loader_overlay)
setLoading(false);
_layoutItems.removeView(_layoutParentComment)
_commentsList.setPrependedView(_layoutParentComment)
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
_onCommentAdded?.invoke(it);
@@ -14,6 +14,10 @@ class SupportOverlay : LinearLayout {
private val _topbar: OverlayTopbar;
private val _support: SupportView;
val hasSupportItems: Boolean get() {
return _support.hasSupportItems;
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_support, this)
_topbar = findViewById(R.id.topbar);
@@ -0,0 +1,38 @@
package com.futo.platformplayer.views.overlays.slideup
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.AnyAdapter
class SlideUpMenuRecycler<T : Any, VType : AnyAdapter.AnyViewHolder<T>> : LinearLayout {
private lateinit var recyclerView: RecyclerView;
private val adapter: AnyAdapterView<T, VType>?;
var groupTag: Any? = null;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
init();
adapter = null;
}
constructor(context: Context, tag: Any, creation: (RecyclerView)->AnyAdapterView<T, VType>) : super(context){
init();
groupTag = tag;
adapter = creation(recyclerView);
}
private fun init(){
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_recycler, this, true);
recyclerView = findViewById(R.id.slide_up_menu_recycler);
}
}
@@ -71,6 +71,9 @@ class CommentsList : ConstraintLayout {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy);
onScrolled();
val totalScrollDistance = recyclerView.computeVerticalScrollOffset()
_layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE
}
};
@@ -82,6 +85,7 @@ class CommentsList : ConstraintLayout {
private var _loading = false;
private val _prependedView: FrameLayout;
private var _readonly: Boolean = false;
private val _layoutScrollToTop: FrameLayout;
var onRepliesClick = Event1<IPlatformComment>();
var onCommentsLoaded = Event1<Int>();
@@ -90,6 +94,13 @@ class CommentsList : ConstraintLayout {
LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true);
_recyclerComments = findViewById(R.id.recycler_comments);
_layoutScrollToTop = findViewById(R.id.layout_scroll_to_top);
_layoutScrollToTop.setOnClickListener {
_recyclerComments.smoothScrollToPosition(0)
}
_layoutScrollToTop.visibility = View.GONE
_textMessage = TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 30, 0, 0)
@@ -582,6 +582,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_videoControls_fullscreen.show();
videoControls.hideImmediately();
videoControls.visibility = View.GONE;
}
else {
val lp = background.layoutParams as ConstraintLayout.LayoutParams;
@@ -594,6 +595,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
videoControls.show();
_videoControls_fullscreen.hideImmediately();
_videoControls_fullscreen.visibility = View.GONE;
}
fitOrFill(fullScreen);
@@ -6,13 +6,11 @@ import static androidx.media3.datasource.HttpUtil.buildRangeRequestHeader;
import static java.lang.Math.min;
import android.net.Uri;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.futo.platformplayer.api.http.ManagedHttpClient;
import com.futo.platformplayer.api.media.models.modifier.IRequest;
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier;
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier;
@@ -26,7 +24,6 @@ import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.datasource.HttpUtil;
import androidx.media3.datasource.TransferListener;
import androidx.work.impl.utils.PackageManagerHelper;
import com.google.common.base.Predicate;
import com.google.common.collect.ForwardingMap;
@@ -42,16 +39,12 @@ import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.NoRouteToHostException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.GZIPInputStream;
import okhttp3.OkHttpClient;
/*
* Based on the default ExoPlayer DefaultHttpDataSource
*/
@@ -322,12 +315,6 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
responseMessage = connection.getResponseMessage();
} catch (IOException e) {
closeConnectionQuietly();
Log.e(TAG, "Failed to open: " + dataSpec.uri, e);
ManagedHttpClient client = new ManagedHttpClient(new OkHttpClient.Builder());
ManagedHttpClient.Response response = client.head(dataSpec.uri.toString(), new HashMap<>());
Log.i(TAG, "ManagedHttpClient response code: " + response.getCode());
throw HttpDataSourceException.createForIOException(
e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
@@ -587,26 +574,12 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
requestHeaders.put(HttpHeaders.ACCEPT_ENCODING, allowGzip ? "gzip" : "identity");
String requestMethod = DataSpec.getStringForHttpMethod(httpMethod);
String requestUrl = url.toString();
if (requestModifier != null) {
IRequest result = requestModifier.modifyRequest(requestUrl, requestHeaders, requestMethod, httpBody != null ? new String(httpBody, StandardCharsets.UTF_8) : null);
IRequest result = requestModifier.modifyRequest(requestUrl, requestHeaders);
String modifiedUrl = result.getUrl();
if (modifiedUrl != null)
requestUrl = modifiedUrl;
Map<String, String> modifiedHeaders = result.getHeaders();
if (modifiedHeaders != null)
requestHeaders = modifiedHeaders;
String modifiedMethod = result.getMethod();
if (modifiedMethod != null)
requestMethod = modifiedMethod;
String modifiedBody = result.getBody();
if (modifiedBody != null)
httpBody = modifiedBody.getBytes(StandardCharsets.UTF_8);
requestUrl = (modifiedUrl != null) ? modifiedUrl : requestUrl;
requestHeaders = result.getHeaders();
}
HttpURLConnection connection = openConnection(new URL(requestUrl));
@@ -619,7 +592,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
connection.setInstanceFollowRedirects(followRedirects);
connection.setDoOutput(httpBody != null);
connection.setRequestMethod(requestMethod);
connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod));
if (httpBody != null) {
connection.setFixedLengthStreamingMode(httpBody.length);
+94 -98
View File
@@ -1,111 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/black"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.futo.platformplayer.views.overlays.OverlayTopbar
android:id="@+id/topbar"
android:layout_width="match_parent"
android:layout_height="40dp"
app:title="Replies"
app:metadata="3 replies"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<LinearLayout android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical"
android:id="@+id/layout_items">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_parent_comment"
android:layout_height="wrap_content"
android:layout_width="match_parent"
app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginTop="6dp"
android:padding="12dp"
android:background="@drawable/background_16_round_4dp">
<com.futo.platformplayer.views.overlays.OverlayTopbar
android:id="@+id/topbar"
android:layout_width="match_parent"
android:layout_height="40dp"
app:title="Replies"
app:metadata="3 replies" />
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/image_thumbnail"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/channel_image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/placeholder_channel_thumbnail" />
<TextView
android:id="@+id/text_author"
android:layout_width="wrap_content"
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toTopOf="@id/image_thumbnail"
tools:text="ShortCircuit" />
android:layout_marginTop="12dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_parent_comment"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/text_author"
app:layout_constraintLeft_toRightOf="@id/text_author"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/text_author"
tools:text=" • 3 years ago" />
android:layout_width="match_parent"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:padding="12dp"
android:background="@drawable/background_16_round_4dp">
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/text_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginStart="10dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_regular"
android:isScrollContainer="false"
android:textColor="#CCCCCC"
android:textSize="13sp"
android:maxLines="3"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent"
tools:text="@string/lorem_ipsum" />
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/image_thumbnail"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/channel_image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/placeholder_channel_thumbnail" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/text_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toTopOf="@id/image_thumbnail"
tools:text="ShortCircuit" />
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
app:layout_constraintTop_toBottomOf="@id/layout_parent_comment"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/text_author"
app:layout_constraintLeft_toRightOf="@id/text_author"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/text_author"
tools:text=" • 3 years ago" />
<com.futo.platformplayer.views.segments.CommentsList
android:id="@+id/comments_list"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/add_comment_view"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginTop="12dp" />
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/text_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginStart="10dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_regular"
android:isScrollContainer="false"
android:textColor="#CCCCCC"
android:textSize="13sp"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent"
tools:text="@string/lorem_ipsum" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.futo.platformplayer.views.segments.CommentsList
android:id="@+id/comments_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp" />
</LinearLayout>
<com.futo.platformplayer.views.overlays.LoaderOverlay
android:id="@+id/loader_overlay"
@@ -113,5 +110,4 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/slide_up_menu_recycler"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
+21 -1
View File
@@ -7,5 +7,25 @@
android:id="@+id/recycler_comments"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="12dp"
android:paddingBottom="7dp"
android:paddingEnd="14dp"
android:paddingTop="7dp"
android:paddingStart="14dp"
android:background="@drawable/background_pill"
android:id="@+id/layout_scroll_to_top">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scroll_to_top"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:textSize="14dp"/>
</FrameLayout>
</FrameLayout>
+2
View File
@@ -625,6 +625,7 @@
<string name="you_have_too_many_subscriptions_for_the_following_plugins">\n\nYou have too many subscriptions for the following plugins:\n</string>
<string name="posts">Posts</string>
<string name="planned">Planned</string>
<string name="watched">Watched</string>
<string name="no_results_found_swipe_down_to_refresh">No results found\nSwipe down to refresh</string>
<string name="overlay">Overlay</string>
<string name="reload">Reload</string>
@@ -750,6 +751,7 @@
<string name="select">Select</string>
<string name="zoom">Zoom</string>
<string name="check_to_see_if_an_update_is_available">Check to see if an update is available.</string>
<string name="scroll_to_top">Scroll to top</string>
<string-array name="home_screen_array">
<item>Recommendations</item>
<item>Subscriptions</item>