mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 21:32:39 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17b9853bb6 | |||
| 8bfb8abd20 | |||
| 9ee3f1f26e | |||
| 5dcff29d8d | |||
| 6cfbd0c8bf | |||
| 01d96cce16 | |||
| 58c376f011 | |||
| 439d339330 | |||
| 44eacc2a47 | |||
| 8135d61398 | |||
| 66208f8265 | |||
| f52251e23a | |||
| dbea93efe5 | |||
| 3bf0740bd1 |
@@ -540,6 +540,8 @@
|
||||
<script>
|
||||
IS_TESTING = true;
|
||||
let lastScriptTag = null;
|
||||
let shouldDevLog = true;
|
||||
let shouldLoginCheck = true;
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
@@ -603,7 +605,7 @@
|
||||
};
|
||||
setInterval(()=>{
|
||||
try{
|
||||
if(!this.Plugin.currentPlugin)
|
||||
if(!this.Plugin.currentPlugin || !shouldDevLog)
|
||||
return;
|
||||
|
||||
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
|
||||
@@ -638,7 +640,8 @@
|
||||
}, 1000);
|
||||
setInterval(()=>{
|
||||
try{
|
||||
this.isTestLoggedIn();
|
||||
if(shouldLoginCheck)
|
||||
this.isTestLoggedIn();
|
||||
}catch(ex){}
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,8 @@ let Type = {
|
||||
Videos: "VIDEOS",
|
||||
Streams: "STREAMS",
|
||||
Mixed: "MIXED",
|
||||
Live: "LIVE"
|
||||
Live: "LIVE",
|
||||
Subscriptions: "SUBSCRIPTIONS"
|
||||
},
|
||||
Order: {
|
||||
Chronological: "CHRONOLOGICAL"
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.activities.*
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -21,6 +22,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -43,10 +45,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(
|
||||
R.string.manage_polycentric_identity, FieldForm.BUTTON,
|
||||
R.string.manage_your_polycentric_identity, -5
|
||||
)
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -58,10 +57,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.show_faq, FieldForm.BUTTON,
|
||||
R.string.get_answers_to_common_questions, -4
|
||||
)
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
|
||||
@FormFieldButton(R.drawable.ic_quiz)
|
||||
fun openFAQ() {
|
||||
try {
|
||||
@@ -71,10 +67,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
@FormField(
|
||||
R.string.show_issues, FieldForm.BUTTON,
|
||||
R.string.a_list_of_user_reported_and_self_reported_issues, -3
|
||||
)
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
|
||||
@FormFieldButton(R.drawable.ic_data_alert)
|
||||
fun openIssues() {
|
||||
try {
|
||||
@@ -106,10 +99,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
R.string.manage_tabs, FieldForm.BUTTON,
|
||||
R.string.change_tabs_visible_on_the_home_screen, -2
|
||||
)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
@@ -123,7 +113,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 0)
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
class LanguageSettings {
|
||||
@@ -166,6 +156,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7)
|
||||
@FormFieldButton(R.drawable.ic_visibility_off)
|
||||
fun clearHidden() {
|
||||
StateMeta.instance.removeAllHiddenCreators();
|
||||
StateMeta.instance.removeAllHiddenVideos();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.search, "group", -1, 2)
|
||||
@@ -252,6 +253,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
ChannelContentCache.instance.clear();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
|
||||
@@ -421,10 +429,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
@FormField(
|
||||
R.string.submit_logs, FieldForm.BUTTON,
|
||||
R.string.submit_logs_to_help_us_narrow_down_issues, 1
|
||||
)
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -446,10 +451,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var announcementSettings = AnnouncementSettings();
|
||||
@Serializable
|
||||
class AnnouncementSettings {
|
||||
@FormField(
|
||||
R.string.reset_announcements, FieldForm.BUTTON,
|
||||
R.string.reset_hidden_announcements, 1
|
||||
)
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun resetAnnouncements() {
|
||||
StateAnnouncement.instance.resetAnnouncements();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||
@@ -465,18 +467,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
@FormField(
|
||||
R.string.clear_cookies, FieldForm.BUTTON,
|
||||
R.string.clears_in_app_browser_cookies, 1
|
||||
)
|
||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(
|
||||
R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
|
||||
R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
|
||||
)
|
||||
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||
fun reinstallEmbedded() {
|
||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -523,6 +519,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
|
||||
fun clearStorageDownload() {
|
||||
Settings.instance.storage.storage_download = null;
|
||||
Settings.instance.save();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -555,10 +558,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.manual_check, FieldForm.BUTTON,
|
||||
R.string.manually_check_for_updates, 3
|
||||
)
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -575,10 +575,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.view_changelog, FieldForm.BUTTON,
|
||||
R.string.review_the_current_and_past_changelogs, 4
|
||||
)
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
@@ -598,10 +595,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(
|
||||
R.string.remove_cached_version, FieldForm.BUTTON,
|
||||
R.string.remove_the_last_downloaded_version, 5
|
||||
)
|
||||
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
|
||||
fun removeCachedVersion() {
|
||||
StateApp.withContext {
|
||||
val outputDirectory = File(it.filesDir, "autoupdate");
|
||||
@@ -687,7 +681,16 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 15)
|
||||
@FormField(R.string.other, FieldForm.GROUP, -1, 15)
|
||||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
||||
var bypassRotationPrevention: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 16)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
||||
@@ -389,8 +389,13 @@ 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), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, 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");
|
||||
}))
|
||||
+ actions)
|
||||
));
|
||||
items.add(
|
||||
|
||||
@@ -164,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
|
||||
}
|
||||
|
||||
fun File.share(context: Context) {
|
||||
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
|
||||
|
||||
fun DocumentFile.share(context: Context) {
|
||||
val shareIntent = Intent();
|
||||
shareIntent.action = Intent.ACTION_SEND;
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
private var _config: SourcePluginConfig? = null;
|
||||
private var _script: String? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,6 +8,7 @@ import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
@@ -43,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_add_source_options);
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.CaptchaWebViewClient
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_captcha);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_exception);
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -28,6 +29,9 @@ class LoginActivity : AppCompatActivity() {
|
||||
private lateinit var _textUrl: TextView;
|
||||
private lateinit var _buttonClose: ImageButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -327,6 +327,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fragCurrent.onOrientationChanged(it);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_fragVideoDetail.onOrientationChanged(it);
|
||||
else if(Settings.instance.other.bypassRotationPrevention)
|
||||
{
|
||||
requestedOrientation = when(orientation) {
|
||||
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
}
|
||||
}
|
||||
};
|
||||
_orientationManager.enable();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.ItemMoveCallback
|
||||
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
|
||||
private lateinit var _recyclerTabs: RecyclerView;
|
||||
private lateinit var _touchHelper: ItemTouchHelper;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_manage_tabs);
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.*
|
||||
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_backup);
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -11,6 +12,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -28,6 +30,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
|
||||
private var _creating = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_create_profile);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonImportProfile: BigButton;
|
||||
private lateinit var _layoutButtons: LinearLayout;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_home);
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.*
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -48,6 +49,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _imagePolycentric: ImageView;
|
||||
private var _avatarUri: Uri? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_profile);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,12 +8,17 @@ import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class PolycentricWhyActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonVideo: BigButton;
|
||||
private lateinit var _buttonTechnical: BigButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_why);
|
||||
|
||||
@@ -190,7 +190,9 @@ class HttpContext : AutoCloseable {
|
||||
do {
|
||||
read = readContentBytes(buffer, buffer.size);
|
||||
writer.write(buffer, 0, read);
|
||||
} while(read > 0);
|
||||
} while(read > 0);// && _stream.ready());
|
||||
//if(!_stream.ready())
|
||||
// _totalRead = contentLength;
|
||||
return writer.toString();
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
|
||||
@@ -200,7 +200,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
handler(req);
|
||||
|
||||
requestsTotal++;
|
||||
if(req.keepAlive) {
|
||||
if(req.keepAlive){// && requestReader.ready()) {
|
||||
keepAlive = true;
|
||||
if(req.keepAliveMax > 0)
|
||||
requestsMax = req.keepAliveMax;
|
||||
|
||||
@@ -29,6 +29,7 @@ class ResultCapabilities(
|
||||
const val TYPE_LIVE = "LIVE";
|
||||
const val TYPE_POSTS = "POSTS";
|
||||
const val TYPE_MIXED = "MIXED";
|
||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||
|
||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
|
||||
@@ -51,6 +51,14 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(_channelContents) {
|
||||
for(channel in _channelContents)
|
||||
for(content in channel.value.getItems())
|
||||
uncacheContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
|
||||
|
||||
@@ -287,7 +287,6 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
@HttpPOST("/plugin/remoteCall")
|
||||
fun pluginRemoteCall(context: HttpContext) {
|
||||
try {
|
||||
val parameters = context.readContentString();
|
||||
val objId = context.query.get("id")
|
||||
val method = context.query.get("method")
|
||||
|
||||
@@ -299,12 +298,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(400, "Missing method");
|
||||
return;
|
||||
}
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
|
||||
val parameters = context.readContentString(); //TODO: Temporary
|
||||
|
||||
val remoteObj = getRemoteObject(objId);
|
||||
val paras = JsonParser.parseString(parameters);
|
||||
if(!paras.isJsonArray)
|
||||
throw IllegalArgumentException("Expected json array as body");
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
val callResult = remoteObj.call(method, paras as JsonArray);
|
||||
val json = wrapRemoteResult(callResult, false);
|
||||
context.respondCode(200, json, "application/json");
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -43,7 +48,7 @@ class VideoExport {
|
||||
this.subtitleSource = subtitleSource;
|
||||
}
|
||||
|
||||
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope {
|
||||
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
|
||||
if(isCancelled) throw CancellationException("Export got cancelled");
|
||||
|
||||
val v = videoSource;
|
||||
@@ -55,34 +60,47 @@ class VideoExport {
|
||||
if (a != null) sourceCount++;
|
||||
if (s != null) sourceCount++;
|
||||
|
||||
var outputFile: File? = null;
|
||||
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
|
||||
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
|
||||
val moviesGrayjay = File(moviesRoot, "Grayjay");
|
||||
val musicGrayjay = File(musicRoot, "Grayjay");
|
||||
if(!moviesGrayjay.exists())
|
||||
moviesGrayjay.mkdirs();
|
||||
if(!musicGrayjay.exists())
|
||||
musicGrayjay.mkdirs();
|
||||
|
||||
val outputFile: DocumentFile?;
|
||||
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
|
||||
if (sourceCount > 1) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = File(moviesGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile("video/mp4", outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Combining video and audio through FFMPEG.");
|
||||
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
|
||||
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
|
||||
try {
|
||||
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete();
|
||||
}
|
||||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = File(moviesGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile(v.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
|
||||
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
|
||||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = File(musicGrayjay, outputFileName);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
|
||||
|
||||
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
|
||||
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
|
||||
}
|
||||
|
||||
outputFile = f;
|
||||
} else {
|
||||
throw Exception("Cannot export when no audio or video source is set.");
|
||||
@@ -179,10 +197,9 @@ class VideoExport {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
|
||||
private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var inputStream: FileInputStream? = null
|
||||
var outputStream: FileOutputStream? = null
|
||||
|
||||
try {
|
||||
val srcFile = File(fromPath)
|
||||
@@ -190,17 +207,7 @@ class VideoExport {
|
||||
throw IOException("Source file not found.")
|
||||
}
|
||||
|
||||
val dstFile = File(toPath)
|
||||
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
|
||||
|
||||
if (!parentDir.exists()) {
|
||||
if (!parentDir.mkdirs()) {
|
||||
throw IOException("Failed to create destination directory.")
|
||||
}
|
||||
}
|
||||
|
||||
inputStream = FileInputStream(srcFile)
|
||||
outputStream = FileOutputStream(dstFile)
|
||||
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val totalBytes = srcFile.length()
|
||||
@@ -221,7 +228,6 @@ class VideoExport {
|
||||
throw IOException("Error occurred while copying file: ${e.message}", e)
|
||||
} finally {
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
|
||||
val price = prices[currency.id]!!;
|
||||
val priceDecimal = (price.toDouble() / 100);
|
||||
withContext(Dispatchers.Main) {
|
||||
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal);
|
||||
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) };
|
||||
return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
|
||||
}
|
||||
|
||||
private fun loadResults() {
|
||||
|
||||
+2
@@ -105,6 +105,8 @@ class VideoDetailFragment : MainFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
|
||||
changeOrientation(OrientationManager.Orientation.PORTRAIT);
|
||||
if(lastOrientation == newOrientation)
|
||||
return;
|
||||
|
||||
|
||||
+3
@@ -1101,9 +1101,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
|
||||
}
|
||||
|
||||
|
||||
video.author.let {
|
||||
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
|
||||
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
|
||||
else
|
||||
_monetization.setPlatformMembership(null, null);
|
||||
}
|
||||
|
||||
_minimize_title.text = video.name;
|
||||
|
||||
@@ -125,6 +125,14 @@ class Subscription {
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
uploadStreamInterval = interval;
|
||||
if(mostRecent != null)
|
||||
|
||||
@@ -107,7 +107,7 @@ class ExportingService : Service() {
|
||||
{
|
||||
try{
|
||||
notifyExport(currentExport);
|
||||
doExport(currentExport);
|
||||
doExport(applicationContext, currentExport);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
|
||||
@@ -125,13 +125,13 @@ class ExportingService : Service() {
|
||||
stopService(this);
|
||||
}
|
||||
|
||||
private suspend fun doExport(export: VideoExport) {
|
||||
private suspend fun doExport(context: Context, export: VideoExport) {
|
||||
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
|
||||
|
||||
export.changeState(VideoExport.State.EXPORTING);
|
||||
|
||||
var lastNotifyTime: Long = 0L;
|
||||
val file = export.export { progress ->
|
||||
val file = export.export(context) { progress ->
|
||||
export.progress = progress;
|
||||
|
||||
val currentTime = System.currentTimeMillis();
|
||||
@@ -146,7 +146,7 @@ class ExportingService : Service() {
|
||||
notifyExport(export);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
|
||||
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
|
||||
file.share(this@ExportingService);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,17 +111,13 @@ class StateApp {
|
||||
return null;
|
||||
}
|
||||
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("External download directory not yet used by export (WIP)");
|
||||
};
|
||||
if(context is Context)
|
||||
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
|
||||
if(it != null)
|
||||
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
|
||||
if(it != null && isValidStorageUri(context, it)) {
|
||||
Logger.i(TAG, "Changed external download directory: ${it}");
|
||||
Settings.instance.storage.storage_general = it.toString();
|
||||
Settings.instance.storage.storage_download = it.toString();
|
||||
Settings.instance.save();
|
||||
|
||||
onChanged?.invoke(getExternalDownloadDirectory(context));
|
||||
|
||||
@@ -400,10 +400,7 @@ class StateDownloads {
|
||||
_exporting.save(videoExport);
|
||||
|
||||
if(notify) {
|
||||
if(videoSource == null)
|
||||
UIDialogs.toast("Exporting [${shortName}]\nIn your music directory under Grayjay");
|
||||
else
|
||||
UIDialogs.toast("Exporting [${shortName}]\nIn your movies directory under Grayjay");
|
||||
UIDialogs.toast("Exporting [${shortName}]");
|
||||
StateApp.withContext { ExportingService.getOrCreateService(it) };
|
||||
onExportsChanged.emit();
|
||||
}
|
||||
|
||||
@@ -5,15 +5,39 @@ import com.futo.platformplayer.stores.StringHashSetStorage
|
||||
|
||||
class StateMeta {
|
||||
val hiddenVideos = FragmentedStorage.get<StringHashSetStorage>("hiddenVideos");
|
||||
val hiddenCreators = FragmentedStorage.get<StringHashSetStorage>("hiddenCreators");
|
||||
|
||||
fun isVideoHidden(videoUrl: String) : Boolean {
|
||||
return hiddenVideos.contains(videoUrl);
|
||||
}
|
||||
fun addHiddenVideo(videoUrl: String) {
|
||||
hiddenVideos.addDistinct(videoUrl);
|
||||
hiddenVideos.save();
|
||||
}
|
||||
fun removeHiddenVideo(videoUrl: String) {
|
||||
hiddenVideos.remove(videoUrl);
|
||||
hiddenVideos.save();
|
||||
}
|
||||
fun removeAllHiddenVideos() {
|
||||
hiddenVideos.removeAll();
|
||||
hiddenVideos.save();
|
||||
}
|
||||
|
||||
|
||||
fun isCreatorHidden(creatorUrl: String): Boolean {
|
||||
return hiddenCreators.contains(creatorUrl);
|
||||
}
|
||||
fun addHiddenCreator(creatorUrl: String) {
|
||||
hiddenCreators.addDistinct(creatorUrl);
|
||||
hiddenCreators.save();
|
||||
}
|
||||
fun removeHiddenCreator(creatorUrl: String) {
|
||||
hiddenCreators.remove(creatorUrl);
|
||||
hiddenCreators.save();
|
||||
}
|
||||
fun removeAllHiddenCreators() {
|
||||
hiddenCreators.removeAll();
|
||||
hiddenCreators.save();
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -246,13 +246,12 @@ class StateSubscriptions {
|
||||
|
||||
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
||||
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
|
||||
if(onNewCacheHit != null)
|
||||
algo.onNewCacheHit.subscribe(onNewCacheHit)
|
||||
|
||||
algo.onProgress.subscribe { progress, total ->
|
||||
onProgress?.invoke(progress, total);
|
||||
}
|
||||
algo.onNewCacheHit.subscribe { sub, content ->
|
||||
|
||||
}
|
||||
|
||||
val usePolycentric = true;
|
||||
val subUrls = getSubscriptions().parallelStream().map {
|
||||
|
||||
@@ -35,6 +35,11 @@ class StringHashSetStorage : FragmentedStorageFileJson() {
|
||||
values.remove(obj);
|
||||
}
|
||||
}
|
||||
fun removeAll() {
|
||||
synchronized(values) {
|
||||
values.clear();
|
||||
}
|
||||
}
|
||||
fun set(vararg objs: String) {
|
||||
synchronized(values) {
|
||||
values.clear();
|
||||
|
||||
+12
-5
@@ -35,6 +35,8 @@ class SmartSubscriptionAlgorithm(
|
||||
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_MIXED));
|
||||
else if(capabilities.hasType(ResultCapabilities.TYPE_SUBSCRIPTIONS))
|
||||
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_SUBSCRIPTIONS))
|
||||
else {
|
||||
val types = listOf(
|
||||
if(sub.shouldFetchVideos()) ResultCapabilities.TYPE_VIDEOS else null,
|
||||
@@ -67,12 +69,17 @@ class SmartSubscriptionAlgorithm(
|
||||
if(limit == null || limit <= 0)
|
||||
finalTasks.addAll(clientTasks.second);
|
||||
else {
|
||||
val fetchTasks = clientTasks.second.take(limit);
|
||||
val cacheTasks = clientTasks.second.drop(limit);
|
||||
|
||||
for(cacheTask in cacheTasks)
|
||||
cacheTask.fromCache = true;
|
||||
val fetchTasks = mutableListOf<SubscriptionTask>();
|
||||
val cacheTasks = mutableListOf<SubscriptionTask>();
|
||||
|
||||
for(task in clientTasks.second) {
|
||||
if(!task.fromCache && fetchTasks.size < limit)
|
||||
fetchTasks.add(task);
|
||||
else {
|
||||
task.fromCache = true;
|
||||
cacheTasks.add(task);
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}")
|
||||
|
||||
finalTasks.addAll(fetchTasks + cacheTasks);
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
for(clientTasks in tasksGrouped) {
|
||||
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
|
||||
val clientCacheCount = clientTasks.value.size - clientTaskCount;
|
||||
if(clientCacheCount > 0 && clientTaskCount > 0 && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
|
||||
val limit = clientTasks.key.getSubscriptionRateLimit();
|
||||
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
|
||||
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.views.adapters.viewholders
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
@@ -7,9 +8,11 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
@@ -47,7 +50,18 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<
|
||||
}
|
||||
_videoExport.setOnClickListener {
|
||||
val v = _video ?: return@setOnClickListener;
|
||||
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
|
||||
if (StateApp.instance.getExternalDownloadDirectory(_view.context) == null) {
|
||||
StateApp.instance.changeExternalDownloadDirectory(_view.context as MainActivity) {
|
||||
if (it == null) {
|
||||
UIDialogs.toast(_view.context, "Download directory must be set to export.");
|
||||
return@changeExternalDownloadDirectory;
|
||||
}
|
||||
|
||||
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
|
||||
};
|
||||
} else {
|
||||
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ class ButtonField : BigButton, IField {
|
||||
|
||||
override var reference: Any? = null;
|
||||
|
||||
override val value: Any? = null;
|
||||
|
||||
override val obj : Any? get() {
|
||||
if(this._obj == null)
|
||||
throw java.lang.IllegalStateException("Can only be called if fromField is used");
|
||||
|
||||
@@ -39,6 +39,8 @@ class DropdownField : TableRow, IField {
|
||||
|
||||
override val onChanged = Event3<IField, Any, Any>();
|
||||
|
||||
override val value: Any? get() = _selected;
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_dropdown, this);
|
||||
_spinner = findViewById(R.id.field_spinner);
|
||||
|
||||
@@ -9,11 +9,16 @@ import java.lang.reflect.Field
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class FormField(val title: Int, val type: String, val subtitle: Int = -1, val order: Int = 0, val id: String = "")
|
||||
|
||||
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class FormFieldWarning(val messageRes: Int)
|
||||
|
||||
interface IField {
|
||||
var descriptor: FormField?;
|
||||
val obj : Any?;
|
||||
val field : Field?;
|
||||
|
||||
val value: Any?;
|
||||
val onChanged : Event3<IField, Any, Any>;
|
||||
|
||||
var reference: Any?;
|
||||
@@ -22,4 +27,17 @@ interface IField {
|
||||
fun setField();
|
||||
|
||||
fun setValue(value: Any);
|
||||
|
||||
companion object {
|
||||
fun isValueTrue(value: Any?): Boolean {
|
||||
if(value == null)
|
||||
return false;
|
||||
return when(value) {
|
||||
is Int -> value > 0;
|
||||
is Boolean -> value;
|
||||
is String -> value.toIntOrNull()?.let { it > 0 } ?: false || value.lowercase() == "true";
|
||||
else -> false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.reflect.full.declaredMemberProperties
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import kotlin.reflect.full.hasAnnotation
|
||||
@@ -103,7 +104,7 @@ class FieldForm : LinearLayout {
|
||||
onChanged.emit(field, value);
|
||||
|
||||
setting.warningDialog?.let {
|
||||
if(it.isNotBlank() && isValueTrue(value))
|
||||
if(it.isNotBlank() && IField.isValueTrue(value))
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, setting.warningDialog, null, null, 0,
|
||||
UIDialogs.Action("Cancel", {
|
||||
field.setValue(oldValue);
|
||||
@@ -118,8 +119,11 @@ class FieldForm : LinearLayout {
|
||||
if(dependentField == null || dependentField.second !is View)
|
||||
(field as View).visibility = View.GONE;
|
||||
else {
|
||||
val dependencyReady = IField.isValueTrue(dependentField.second.value);
|
||||
if(!dependencyReady)
|
||||
(field as View).visibility = View.GONE;
|
||||
dependentField.second.onChanged.subscribe { dependentField, value, oldValue ->
|
||||
val isValid = isValueTrue(value);
|
||||
val isValid = IField.isValueTrue(value);
|
||||
if(isValid)
|
||||
(field as View).visibility = View.VISIBLE;
|
||||
else
|
||||
@@ -128,14 +132,6 @@ class FieldForm : LinearLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun isValueTrue(value: Any): Boolean {
|
||||
return when(value) {
|
||||
is Int -> value > 0;
|
||||
is Boolean -> value;
|
||||
is String -> value.toIntOrNull()?.let { it > 0 } ?: false || value.lowercase() == "true";
|
||||
else -> false
|
||||
};
|
||||
}
|
||||
|
||||
fun setObjectValues(){
|
||||
val fields = _fields;
|
||||
@@ -213,21 +209,42 @@ class FieldForm : LinearLayout {
|
||||
.asSequence()
|
||||
.asStream()
|
||||
.filter { it.hasAnnotation<FormField>() && it.javaField != null }
|
||||
.map { Pair<Field, FormField>(it.javaField!!, it.findAnnotation()!!) }
|
||||
.map { Pair<KProperty<*>, FormField>(it, it.findAnnotation()!!) }
|
||||
.toList()
|
||||
|
||||
//TODO: Rewrite fields to properties so no map is required
|
||||
val propertyMap = mutableMapOf<Field, KProperty<*>>();
|
||||
val fields = mutableListOf<IField>();
|
||||
for(prop in objFields) {
|
||||
prop.first.isAccessible = true;
|
||||
prop.first.javaField!!.isAccessible = true;
|
||||
|
||||
val field = when(prop.second.type) {
|
||||
GROUP -> GroupField(context).fromField(obj, prop.first, prop.second);
|
||||
DROPDOWN -> DropdownField(context).fromField(obj, prop.first, prop.second);
|
||||
TOGGLE -> ToggleField(context).fromField(obj, prop.first, prop.second);
|
||||
READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first, prop.second);
|
||||
GROUP -> GroupField(context).fromField(obj, prop.first.javaField!!, prop.second);
|
||||
DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second);
|
||||
TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second);
|
||||
READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first.javaField!!, prop.second);
|
||||
else -> throw java.lang.IllegalStateException("Unknown field type ${prop.second.type} for ${prop.second.title}")
|
||||
}
|
||||
fields.add(field as IField);
|
||||
propertyMap.put(prop.first.javaField!!, prop.first);
|
||||
}
|
||||
|
||||
for(field in fields) {
|
||||
if(field.field != null) {
|
||||
val warning = propertyMap[field.field]?.findAnnotation<FormFieldWarning>();
|
||||
if(warning != null) {
|
||||
field.onChanged.subscribe { field, value, oldValue ->
|
||||
if(IField.isValueTrue(value))
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, context.getString(warning.messageRes), null, null, 0,
|
||||
UIDialogs.Action("Cancel", {
|
||||
field.setValue(oldValue);
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Ok", {
|
||||
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val objProps = obj::class.declaredMemberProperties
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import java.lang.reflect.Field
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class GroupField : LinearLayout, IField {
|
||||
override var descriptor : FormField? = null;
|
||||
@@ -36,6 +37,8 @@ class GroupField : LinearLayout, IField {
|
||||
|
||||
override var reference: Any? = null;
|
||||
|
||||
override val value: Any? = null;
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.field_group, this);
|
||||
_title = findViewById(R.id.field_group_title);
|
||||
|
||||
@@ -31,6 +31,9 @@ class ReadOnlyTextField : TableRow, IField {
|
||||
override val onChanged = Event3<IField, Any, Any>();
|
||||
|
||||
override var reference: Any? = null;
|
||||
|
||||
override val value: Any? = null;
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_readonly_text, this);
|
||||
_title = findViewById(R.id.field_title);
|
||||
|
||||
@@ -36,6 +36,8 @@ class ToggleField : TableRow, IField {
|
||||
|
||||
override val onChanged = Event3<IField, Any, Any>();
|
||||
|
||||
override val value: Any get() = _lastValue;
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_toggle, this);
|
||||
_toggle = findViewById(R.id.field_toggle);
|
||||
|
||||
@@ -219,7 +219,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
updateRotateLock();
|
||||
};
|
||||
_control_cast.setOnClickListener {
|
||||
|
||||
UIDialogs.showCastingDialog(context);
|
||||
};
|
||||
|
||||
_control_minimize_fullscreen.setOnClickListener {
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
android:id="@+id/button_buy_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="$9.99"
|
||||
android:text="$9.99 + Tax"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<string name="add_to">Add to</string>
|
||||
<string name="lorem_ipsum" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
|
||||
<string name="add_to_queue">Add to queue</string>
|
||||
<string name="general">General</string>
|
||||
<string name="home">Home</string>
|
||||
<string name="recommendations">Recommendations</string>
|
||||
<string name="more">More</string>
|
||||
@@ -277,9 +278,11 @@
|
||||
<string name="casting">Casting</string>
|
||||
<string name="change_behavior_of_the_player">Change behavior of the player</string>
|
||||
<string name="change_external_downloads_directory">Change external Downloads directory</string>
|
||||
<string name="clear_external_downloads_directory">Clear external Downloads directory</string>
|
||||
<string name="change_external_general_directory">Change external General directory</string>
|
||||
<string name="change_tabs_visible_on_the_home_screen">Change tabs visible on the home screen</string>
|
||||
<string name="change_the_external_directory_for_general_files">Change the external directory for general files</string>
|
||||
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
||||
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
||||
<string name="clear_cookies">Clear Cookies</string>
|
||||
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
||||
@@ -312,6 +315,7 @@
|
||||
<string name="import_data_description">Select a file to import, support various files (alternative to opening directly)</string>
|
||||
<string name="external_storage">External Storage</string>
|
||||
<string name="feed_style">Feed Style</string>
|
||||
<string name="language">Language</string>
|
||||
<string name="app_language">App Language</string>
|
||||
<string name="may_require_restart">May require restart</string>
|
||||
<string name="fetch_on_app_boot">Fetch on app boot</string>
|
||||
@@ -336,6 +340,9 @@
|
||||
<string name="number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources">Number of concurrent threads to multiply download speeds from throttled sources</string>
|
||||
<string name="payment">Payment</string>
|
||||
<string name="payment_status">Payment Status</string>
|
||||
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
|
||||
<string name="bypass_rotation_prevention_description">Allows for rotation on non-video views.\nWARNING: Not designed for it</string>
|
||||
<string name="bypass_rotation_prevention_warning">This may cause unexpected behavior, and is mostly untested.</string>
|
||||
<string name="player">Player</string>
|
||||
<string name="plugins">Plugins</string>
|
||||
<string name="preferred_casting_quality">Preferred Casting Quality</string>
|
||||
@@ -345,6 +352,8 @@
|
||||
<string name="reinstall_embedded_plugins">Reinstall Embedded Plugins</string>
|
||||
<string name="remove_cached_version">Remove Cached Version</string>
|
||||
<string name="remove_the_last_downloaded_version">Remove the last downloaded version</string>
|
||||
<string name="clear_hidden">Clear Hidden</string>
|
||||
<string name="clear_hidden_description">Removes all hidden creators and videos, showing them again</string>
|
||||
<string name="reset_announcements">Reset announcements</string>
|
||||
<string name="reset_hidden_announcements">Reset hidden announcements</string>
|
||||
<string name="restore_automatic_backup">Restore Automatic Backup</string>
|
||||
@@ -358,6 +367,8 @@
|
||||
<string name="specify_how_many_threads_are_used_to_fetch_channels">Specify how many threads are used to fetch channels</string>
|
||||
<string name="submit_feedback">Submit feedback</string>
|
||||
<string name="submit_logs">Submit logs</string>
|
||||
<string name="clear_channel_cache">Clear Channel Cache</string>
|
||||
<string name="clear_channel_cache_description">Deletes all content from subscription channel cache</string>
|
||||
<string name="submit_logs_to_help_us_narrow_down_issues">Submit logs to help us narrow down issues</string>
|
||||
<string name="subscription_concurrency">Subscription Concurrency</string>
|
||||
<string name="track_playtime_locally">Track Playtime Locally</string>
|
||||
@@ -489,6 +500,7 @@
|
||||
<string name="page">Page</string>
|
||||
<string name="hide">Hide</string>
|
||||
<string name="hide_from_home">Hide from Home</string>
|
||||
<string name="hide_creator_from_home">Hide Creator from Home</string>
|
||||
<string name="play_feed_as_queue">Play Feed as Queue</string>
|
||||
<string name="play_entire_feed">Play entire feed</string>
|
||||
<string name="queued">Queued</string>
|
||||
@@ -644,6 +656,7 @@
|
||||
<string name="load_more">Load More</string>
|
||||
<string name="stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more">Stopped after {requestCount} to avoid rate limit, click load more to load more.</string>
|
||||
<string name="this_creator_has_not_setup_any_monetization_features">This creator has not setup any monetization features</string>
|
||||
<string name="plus_tax">" + Tax"</string>
|
||||
<string-array name="home_screen_array">
|
||||
<item>Recommendations</item>
|
||||
<item>Subscriptions</item>
|
||||
|
||||
Submodule app/src/stable/assets/sources/rumble updated: 3aa9acaefe...60a7ee2ddf
Submodule app/src/stable/assets/sources/youtube updated: 7d5710a4bd...4f89b4072f
Submodule app/src/unstable/assets/sources/rumble updated: 3aa9acaefe...60a7ee2ddf
Submodule app/src/unstable/assets/sources/youtube updated: 7d5710a4bd...4f89b4072f
Reference in New Issue
Block a user