Compare commits

...

35 Commits

Author SHA1 Message Date
Kelvin 34ba44ffa4 WIP Subscription notifs 2023-11-01 00:36:01 +01:00
Kelvin b3a3e459a4 WIP Smart subscriptions 2023-11-01 00:09:05 +01:00
Kelvin f234564952 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 19:22:22 +01:00
Kelvin ffa5795cc9 WIP new subscription system and ui 2023-10-30 19:22:17 +01:00
Koen 4f50c51356 Fixed context crash. 2023-10-30 14:37:49 +01:00
Koen 9e9c8a0bec Fixed Polycentric not backfilling issue 2023-10-30 11:44:56 +01:00
Koen 1349358d7c Added QRCaptureActivity and AudioNoisyReceiver to manifest. 2023-10-30 11:21:51 +01:00
Koen 9c50f15be7 Processed community feedback on German translations. Thank you McIrco95. 2023-10-30 11:08:29 +01:00
Koen 31e771daca Processed community feedback on german translations. Thank you Allstreamer. 2023-10-30 10:59:24 +01:00
Koen 66ce156dea Processed community feedback on translations. Thank you jorpilo. 2023-10-30 10:47:40 +01:00
Koen db6756bc78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 09:22:34 +01:00
Koen cab2581476 Added more logging related to backfill and made backfill properly throw. 2023-10-30 09:22:25 +01:00
Kelvin 4c0be35020 Fix ContentTypes docs 2023-10-29 18:28:52 +00:00
Koen 7114201c08 Translations 2023-10-27 20:01:22 +02:00
Kelvin d8aecd325b Basic chapter system working 2023-10-25 20:38:57 +02:00
Koen 1d18c13817 Updated submodule. 2023-10-25 20:05:03 +02:00
Koen f65eb0cd53 Finished moving view strings to strings.xml 2023-10-25 19:54:48 +02:00
Koen 206c3884e9 Finished moving strings to strings.xml for activities and fragments. 2023-10-25 14:53:50 +02:00
Koen 35f9173980 Started moving all strings to strings.xml 2023-10-25 12:16:58 +02:00
Kelvin 48ab77eadc Patreon refs 2023-10-24 23:58:39 +02:00
Kelvin f486513105 Casting HLS fixed 2023-10-24 23:10:15 +02:00
Kelvin f338adf033 Fix polycentric profile content ordering and deduplication 2023-10-24 22:16:10 +02:00
Kelvin 74be667114 Retain login and captcha on embedded update, Play entire feed option 2023-10-24 14:47:34 +02:00
Kelvin b5a1fc92dc Add misisng synchronization, unsub all dev action, Dedup capital insensitive and more scaling max video date difference 2023-10-23 22:38:13 +02:00
Kelvin 9cec1a8c49 Stable ref updates 2023-10-23 21:03:23 +02:00
Kelvin d4afba929b Fix captcha, FAQ, issues page, icons on settings buttons 2023-10-23 20:36:26 +02:00
Koen 70939cbac6 Fixed log submission and added telemetry OS version. 2023-10-23 16:31:50 +02:00
Koen a3aa61df6d Fixed Odysee get channel contents. 2023-10-23 15:24:55 +02:00
Koen e13ab5cb40 Deduplicated map. 2023-10-23 15:23:46 +02:00
Koen d059947925 Odysee now works with more different types of channel URLs. 2023-10-23 14:24:28 +02:00
Koen d6c4b730de Fixes to Polycentric data display. 2023-10-23 14:21:10 +02:00
Koen 8241863170 Fixed comment alpha. 2023-10-21 16:06:05 +02:00
Koen 31a758e4f3 Updated stable plugins. 2023-10-20 19:57:46 +02:00
Kelvin ca971a0e77 Fix playlist edit name 2023-10-20 19:13:44 +02:00
Kelvin a45a0f9a8a Fix soundcloud missing whitelist domain 2023-10-20 18:40:13 +02:00
225 changed files with 9520 additions and 1920 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Thank you for your interest in contributing! This document outlines how you can
### License
The official plugins for this project are licensed under GPLv3. Any contributions you make will also fall under the GPLv3 license.
The official plugins for this project are licensed under AGPL. Any contributions you make will also fall under the AGPL license.
### How to Contribute
+2 -2
View File
@@ -38,6 +38,7 @@
android:enabled="true" />
<receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
<activity
android:name=".activities.MainActivity"
@@ -182,9 +183,8 @@
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity"
android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
+6
View File
@@ -31,6 +31,12 @@ let Type = {
RAW: 0,
HTML: 1,
MARKUP: 2
},
Chapter: {
NORMAL: 0,
SKIPPABLE: 5,
SKIP: 6
}
};
@@ -35,4 +35,8 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
@@ -22,6 +22,7 @@ import com.futo.platformplayer.views.FeedStyle
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -44,9 +45,10 @@ class Settings : FragmentedStorageFileJson() {
val onTabsChanged = Event0();
@FormField(
"Manage Polycentric identity", FieldForm.BUTTON,
"Manage your Polycentric identity", -3
R.string.manage_polycentric_identity, FieldForm.BUTTON,
R.string.manage_your_polycentric_identity, -4
)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) {
@@ -58,12 +60,26 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(
"Open FAQ", FieldForm.BUTTON,
"Get answers to common questions", -2
R.string.show_faq, FieldForm.BUTTON,
R.string.get_answers_to_common_questions, -3
)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://grayjay.app/faq.html"))
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField(
R.string.show_issues, FieldForm.BUTTON,
R.string.a_list_of_user_reported_and_self_reported_issues, -2
)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
@@ -71,9 +87,10 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(
"Submit feedback", FieldForm.BUTTON,
"Give feedback on the application", -1
R.string.submit_feedback, FieldForm.BUTTON,
R.string.give_feedback_on_the_application, -1
)
@FormFieldButton(R.drawable.ic_bug)
fun submitFeedback() {
try {
val i = Intent(Intent.ACTION_VIEW);
@@ -90,9 +107,10 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(
"Manage Tabs", FieldForm.BUTTON,
"Change tabs visible on the home screen", -1
R.string.manage_tabs, FieldForm.BUTTON,
R.string.change_tabs_visible_on_the_home_screen, -1
)
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
SettingsActivity.getActivity()?.let {
@@ -103,11 +121,11 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Home", "group", "Configure how your Home tab works and feels", 1)
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
var home = HomeSettings();
@Serializable
class HomeSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1;
@@ -119,16 +137,16 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Search", "group", "", 2)
@FormField(R.string.search, "group", -1, 2)
var search = SearchSettings();
@Serializable
class SearchSettings {
@FormField("Search History", FieldForm.TOGGLE, "", 4)
@FormField(R.string.search_history, FieldForm.TOGGLE, -1, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true;
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1;
@@ -141,11 +159,11 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3)
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
var subscriptions = SubscriptionsSettings();
@Serializable
class SubscriptionsSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1;
@@ -156,11 +174,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL;
}
@FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6)
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7)
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
@DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -176,7 +194,7 @@ class Settings : FragmentedStorageFileJson() {
};
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8)
@DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3;
@@ -185,17 +203,17 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Player", "group", "Change behavior of the player", 4)
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
var playback = PlaybackSettings();
@Serializable
class PlaybackSettings {
@FormField("Primary Language", FieldForm.DROPDOWN, "", 0)
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage];
@FormField("Default Playback Speed", FieldForm.DROPDOWN, "", 1)
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@DropdownFieldOptionsId(R.array.playback_speeds)
var defaultPlaybackSpeed: Int = 3;
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@@ -211,29 +229,29 @@ class Settings : FragmentedStorageFileJson() {
else -> 1.0f;
};
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredQuality: Int = 0;
@FormField("Preferred Metered Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredMeteredQuality: Int = 0;
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
@FormField("Preferred Preview Quality", FieldForm.DROPDOWN, "", 3)
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, -1, 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField("Auto-Rotate", FieldForm.DROPDOWN, "", 4)
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2;
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "This prevents the device from rotating within the given amount of degrees.", 5)
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 5)
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0;
@@ -241,19 +259,19 @@ class Settings : FragmentedStorageFileJson() {
return autoRotateDeadZone * 5;
}
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6)
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2;
fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@FormField("Resume After Preview", FieldForm.DROPDOWN, "When watching a video in preview mode, resume at the position when opening the video", 7)
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1;
@FormField("Live Chat Webview", FieldForm.TOGGLE, "Use the live chat web window when available over native implementation.", 8)
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{
@@ -265,12 +283,12 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Downloads", "group", "Configure downloading of videos", 5)
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
var downloads = Downloads();
@Serializable
class Downloads {
@FormField("Download when", FieldForm.DROPDOWN, "Configure when videos should be downloaded", 0)
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_videos_should_be_downloaded, 0)
@DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0;
@@ -283,21 +301,21 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Default Video Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.default_video_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_video_download)
var preferredVideoQuality: Int = 4;
fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality);
@FormField("Default Audio Quality", FieldForm.DROPDOWN, "", 3)
@FormField(R.string.default_audio_quality, FieldForm.DROPDOWN, -1, 3)
@DropdownFieldOptionsId(R.array.preferred_audio_download)
var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
@FormField("ByteRange Download", FieldForm.TOGGLE, "Attempt to utilize byte ranges, this can be combined with concurrency to bypass throttling", 4)
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true;
@FormField("ByteRange Concurrency", FieldForm.DROPDOWN, "Number of concurrent threads to multiply download speeds from throttled sources", 5)
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
@DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3;
fun getByteRangeThreadCount(): Int {
@@ -305,20 +323,20 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Browsing", "group", "Configure browsing behavior", 6)
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6)
var browsing = Browsing();
@Serializable
class Browsing {
@FormField("Enable Video Cache", FieldForm.TOGGLE, "A cache to quickly load previously fetched videos", 0)
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var videoCache: Boolean = true;
}
@FormField("Casting", "group", "Configure casting", 7)
@FormField(R.string.casting, "group", R.string.configure_casting, 7)
var casting = Casting();
@Serializable
class Casting {
@FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0)
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enable_casting, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true;
@@ -340,24 +358,24 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField("Logging", FieldForm.GROUP, "", 8)
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
var logging = Logging();
@Serializable
class Logging {
@FormField("Log Level", FieldForm.DROPDOWN, "", 0)
@FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0;
@FormField(
"Submit logs", FieldForm.BUTTON,
"Submit logs to help us narrow down issues", 1
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 {
if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) {
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Please enable logging to submit logs") }
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
}
}
} catch (e: Throwable) {
@@ -369,40 +387,40 @@ class Settings : FragmentedStorageFileJson() {
@FormField("Announcement", FieldForm.GROUP, "", 10)
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
var announcementSettings = AnnouncementSettings();
@Serializable
class AnnouncementSettings {
@FormField(
"Reset announcements", FieldForm.BUTTON,
"Reset hidden announcements", 1
R.string.reset_announcements, FieldForm.BUTTON,
R.string.reset_hidden_announcements, 1
)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
UIDialogs.toast("Announcements reset.");
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
}
}
@FormField("Plugins", FieldForm.GROUP, "", 11)
@FormField(R.string.plugins, FieldForm.GROUP, -1, 11)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@FormField("Clear Cookies on Logout", FieldForm.TOGGLE, "Clears cookies when you log out, allowing you to change account.", 0)
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@FormField(
"Clear Cookies", FieldForm.BUTTON,
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1
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(
"Reinstall Embedded Plugins", FieldForm.BUTTON,
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1
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) {
@@ -411,7 +429,7 @@ class Settings : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
StateApp.instance.contextOrNull?.let {
UIDialogs.toast(it, "Embedded plugins reinstalled, a reboot is recommended");
UIDialogs.toast(it, it.getString(R.string.embedded_plugins_reinstalled_a_reboot_is_recommended));
};
}
} catch (ex: Exception) {
@@ -426,7 +444,7 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField("External Storage", FieldForm.GROUP, "", 12)
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
var storage = Storage();
@Serializable
class Storage {
@@ -438,13 +456,13 @@ class Settings : FragmentedStorageFileJson() {
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
@@ -453,19 +471,19 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField("Auto Update", "group", "Configure the auto updater", 12)
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 12)
var autoUpdate = AutoUpdate();
@Serializable
class AutoUpdate {
@FormField("Check", FieldForm.DROPDOWN, "", 0)
@FormField(R.string.check, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0;
@FormField("Background download", FieldForm.DROPDOWN, "Configure if background download should be used", 1)
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0;
@FormField("Download when", FieldForm.DROPDOWN, "Configure when updates should be downloaded", 2)
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0;
@@ -483,8 +501,8 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(
"Manual check", FieldForm.BUTTON,
"Manually check for updates", 3
R.string.manual_check, FieldForm.BUTTON,
R.string.manually_check_for_updates, 3
)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
@@ -496,19 +514,20 @@ class Settings : FragmentedStorageFileJson() {
try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) {
UIDialogs.toast(it, "Failed to show store.");
UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
}
}
}
}
@FormField(
"View changelog", FieldForm.BUTTON,
"Review the current and past changelogs", 4
R.string.view_changelog, FieldForm.BUTTON,
R.string.review_the_current_and_past_changelogs, 4
)
fun viewChangelog() {
UIDialogs.toast("Retrieving changelog");
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
@@ -525,8 +544,8 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(
"Remove Cached Version", FieldForm.BUTTON,
"Remove the last downloaded version", 5
R.string.remove_cached_version, FieldForm.BUTTON,
R.string.remove_the_last_downloaded_version, 5
)
fun removeCachedVersion() {
StateApp.withContext {
@@ -543,7 +562,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Backup", FieldForm.GROUP, "", 13)
@FormField(R.string.backup, FieldForm.GROUP, -1, 13)
var backup = Backup();
@Serializable
class Backup {
@@ -553,58 +572,58 @@ class Settings : FragmentedStorageFileJson() {
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
@FormField("Automatic Backup", FieldForm.READONLYTEXT, "", 0)
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
}
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2)
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!!
if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, "You don't have any automatic backups", false);
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
else
UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope);
}
@FormField("Export Data", FieldForm.BUTTON, "Creates a zip file with your data which can be imported by opening it with Grayjay", 3)
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() {
StateBackup.startExternalBackup();
}
}
@FormField("Payment", FieldForm.GROUP, "", 14)
@FormField(R.string.payment, FieldForm.GROUP, -1, 14)
var payment = Payment();
@Serializable
class Payment {
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1)
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid";
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField("Clear Payment", FieldForm.BUTTON, "Deletes license keys from app", 2)
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Licenses cleared, might require app restart");
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
}
}
@FormField("Info", FieldForm.GROUP, "", 15)
@FormField(R.string.info, FieldForm.GROUP, -1, 15)
var info = Info();
@Serializable
class Info {
@FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code")
@FormField(R.string.version_code, FieldForm.READONLYTEXT, -1, 1, "code")
var versionCode = BuildConfig.VERSION_CODE;
@FormField("Version Name", FieldForm.READONLYTEXT, "", 2)
@FormField(R.string.version_name, FieldForm.READONLYTEXT, -1, 2)
var versionName = BuildConfig.VERSION_NAME;
@FormField("Version Type", FieldForm.READONLYTEXT, "", 3)
@FormField(R.string.version_type, FieldForm.READONLYTEXT, -1, 3)
var versionType = BuildConfig.BUILD_TYPE;
}
@@ -615,6 +634,7 @@ class Settings : FragmentedStorageFileJson() {
companion object {
private const val TAG = "Settings";
const val URL_FAQ = "https://grayjay.app/faq.html";
private var _isFirst = true;
@@ -17,6 +17,7 @@ import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.fields.FieldForm
@@ -33,22 +34,22 @@ import kotlin.system.measureTimeMillis
@Serializable()
class SettingsDev : FragmentedStorageFileJson() {
@FormField("Developer Mode", FieldForm.TOGGLE, "", 0)
@FormField(R.string.developer_mode, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var developerMode: Boolean = false;
@FormField("Development Server", FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1)
@FormField(R.string.development_server, FieldForm.GROUP,
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 1)
val devServerSettings: DeveloperServerFields = DeveloperServerFields();
@Serializable
class DeveloperServerFields {
@FormField("Start Server on boot", FieldForm.TOGGLE, "", 0)
@FormField(R.string.start_server_on_boot, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var devServerOnBoot: Boolean = false;
@FormField("Start Server", FieldForm.BUTTON,
"Starts a DevServer on port 11337, may expose vulnerabilities.", 1)
@FormField(R.string.start_server, FieldForm.BUTTON,
R.string.starts_a_devServer_on_port_11337_may_expose_vulnerabilities, 1)
fun startServer() {
StateDeveloper.instance.runServer();
StateApp.instance.contextOrNull?.let {
@@ -57,31 +58,31 @@ class SettingsDev : FragmentedStorageFileJson() {
}
}
@FormField("Experimental", FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2)
@FormField(R.string.experimental, FieldForm.GROUP,
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 2)
val experimentalSettings: ExperimentalFields = ExperimentalFields();
@Serializable
class ExperimentalFields {
@FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0)
@FormField(R.string.background_subscription_testing, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var backgroundSubscriptionFetching: Boolean = false;
}
@FormField("Crash Me", FieldForm.BUTTON,
"Crashes the application on purpose", 2)
@FormField(R.string.crash_me, FieldForm.BUTTON,
R.string.crashes_the_application_on_purpose, 2)
fun crashMe() {
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
}
@FormField("Delete Announcements", FieldForm.BUTTON,
"Delete all announcements", 2)
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
R.string.delete_all_announcements, 2)
fun deleteAnnouncements() {
StateAnnouncement.instance.deleteAllAnnouncements();
}
@FormField("Clear Cookies", FieldForm.BUTTON,
"Clear all cook from the CookieManager", 2)
@FormField(R.string.clear_cookies, FieldForm.BUTTON,
R.string.clear_all_cookies_from_the_cookieManager, 2)
fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null);
@@ -89,13 +90,13 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual
@Transient
@FormField("V8 Benchmarks", FieldForm.GROUP,
"Various benchmarks using the integrated V8 engine", 3)
@FormField(R.string.v8_benchmarks, FieldForm.GROUP,
R.string.various_benchmarks_using_the_integrated_v8_engine, 3)
val v8Benchmarks: V8Benchmarks = V8Benchmarks();
class V8Benchmarks {
@FormField(
"Test V8 Creation speed", FieldForm.BUTTON,
"Tests V8 creation times and running", 1
R.string.test_v8_creation_speed, FieldForm.BUTTON,
R.string.tests_v8_creation_times_and_running, 1
)
fun testV8Creation() {
var plugin: V8Plugin? = null;
@@ -137,8 +138,8 @@ class SettingsDev : FragmentedStorageFileJson() {
}
@FormField(
"Test V8 Communication speed", FieldForm.BUTTON,
"Tests V8 communication speeds", 2
R.string.test_v8_communication_speed, FieldForm.BUTTON,
R.string.tests_v8_communication_speeds, 2
)
fun testV8RunSpeeds() {
var plugin: V8Plugin? = null;
@@ -182,12 +183,12 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual
@Transient
@FormField("V8 Script Testing", FieldForm.GROUP, "Various tests against a custom source", 4)
@FormField(R.string.v8_script_testing, FieldForm.GROUP, R.string.various_tests_against_a_custom_source, 4)
val v8ScriptTests: V8ScriptTests = V8ScriptTests();
class V8ScriptTests {
@Contextual
private var _currentPlugin : JSClient? = null;
@FormField("Inject", FieldForm.BUTTON, "Injects a test source config (local) into V8", 1)
@FormField(R.string.inject, FieldForm.BUTTON, R.string.injects_a_test_source_config_local_into_v8, 1)
fun testV8Init() {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
@@ -203,7 +204,7 @@ class SettingsDev : FragmentedStorageFileJson() {
}
}
}
@FormField("getHome", FieldForm.BUTTON, "Attempts to fetch 2 pages from getHome", 2)
@FormField(R.string.getHome, FieldForm.BUTTON, R.string.attempts_to_fetch_2_pages_from_getHome, 2)
fun testV8Home() {
runTestPlugin(_currentPlugin) {
var home: IPager<IPlatformContent>? = null;
@@ -269,27 +270,36 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual
@Transient
@FormField("Other", FieldForm.GROUP, "Others...", 5)
@FormField(R.string.other, FieldForm.GROUP, R.string.others_ellipsis, 5)
val otherTests: OtherTests = OtherTests();
class OtherTests {
@FormField("Clear Downloads", FieldForm.BUTTON, "Deletes all ongoing downloads", 1)
@FormField(R.string.unsubscribe_all, FieldForm.BUTTON, R.string.removes_all_subscriptions, -1)
fun unsubscribeAll() {
val toUnsub = StateSubscriptions.instance.getSubscriptions();
UIDialogs.toast("Started unsubbing.. (${toUnsub.size})")
toUnsub.forEach {
StateSubscriptions.instance.removeSubscription(it.channel.url);
};
UIDialogs.toast("Finished unsubbing.. (${toUnsub.size})")
}
@FormField(R.string.clear_downloads, FieldForm.BUTTON, R.string.deletes_all_ongoing_downloads, 1)
fun clearDownloads() {
StateDownloads.instance.getDownloading().forEach {
StateDownloads.instance.removeDownload(it);
};
}
@FormField("Clear All Downloaded", FieldForm.BUTTON, "Deletes all downloaded videos and related files", 2)
@FormField(R.string.clear_all_downloaded, FieldForm.BUTTON, R.string.deletes_all_downloaded_videos_and_related_files, 2)
fun clearDownloaded() {
StateDownloads.instance.getDownloadedVideos().forEach {
StateDownloads.instance.deleteCachedVideo(it.id);
};
}
@FormField("Delete Unresolved", FieldForm.BUTTON, "Deletes all unresolved source files", 3)
@FormField(R.string.delete_unresolved, FieldForm.BUTTON, R.string.deletes_all_unresolved_source_files, 3)
fun cleanupDownloads() {
StateDownloads.instance.cleanupDownloads();
}
@FormField("Fill storage till error", FieldForm.BUTTON, "Writes to disk till no space is left", 4)
@FormField(R.string.fill_storage_till_error, FieldForm.BUTTON, R.string.writes_to_disk_till_no_space_is_left, 4)
fun fillStorage(context: Context, scope: CoroutineScope?) {
val gigabuffer = ByteArray(1024 * 1024 * 128);
var count: Long = 0;
@@ -100,12 +100,12 @@ class UIDialogs {
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
UIDialogs.Action("Cancel", {}), //To nothing
UIDialogs.Action("Override", {
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", {
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
else {
@@ -211,10 +211,10 @@ class UIDialogs {
(if(ex != null ) "${ex.message}" else ""),
if(ex is PluginException) ex.code else null,
0,
UIDialogs.Action("Retry", {
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Close", {
UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE)
);
@@ -226,15 +226,15 @@ class UIDialogs {
}
fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, closeButtonAction, retryButtonAction)
val retryButtonAction = Action(context.getString(R.string.retry), retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action(context.getString(R.string.close), closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, context.getString(R.string.data_retry), reason, null, 0, closeButtonAction, retryButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY)
val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT)
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
@@ -1,8 +1,12 @@
package com.futo.platformplayer
import android.content.ContentResolver
import android.graphics.Color
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
@@ -16,6 +20,7 @@ import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
@@ -45,6 +50,64 @@ class UISlideOverlays {
menu.show();
}
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
val originalNotif = subscription.doNotifications;
val originalLive = subscription.doFetchLive;
val originalStream = subscription.doFetchStreams;
val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts;
items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
}, false)));
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
StateSubscriptions.instance.saveSubscription(subscription);
menu.hide(true);
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save");
menu.show();
}
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
@@ -64,22 +127,22 @@ class UISlideOverlays {
val subtitleSources = video.subtitles;
if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) {
UIDialogs.toast("No downloads available", false);
UIDialogs.toast(container.context.getString(R.string.no_downloads_available), false);
return null;
}
if(!VideoHelper.isDownloadable(video)) {
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
UIDialogs.toast( "No downloadable sources (yet)");
UIDialogs.toast( container.context.getString(R.string.no_downloadable_sources_yet));
return null;
}
items.add(SlideUpMenuGroup(container.context, "Video", videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
selectedVideo = null;
menu?.selectOption(videoSources, "none");
if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false)) +
videoSources
.filter { it.isDownloadable() }
@@ -88,7 +151,7 @@ class UISlideOverlays {
selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false)
}).flatten().toList()
));
@@ -100,13 +163,13 @@ class UISlideOverlays {
audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it);
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false);
}));
val asources = audioSources;
@@ -125,7 +188,7 @@ class UISlideOverlays {
//ContentResolver is required for subtitles..
if(contentResolver != null) {
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
.map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
if (selectedSubtitle == it) {
@@ -139,7 +202,7 @@ class UISlideOverlays {
}));
}
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items);
menu = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items);
if(selectedVideo != null) {
menu.selectOption(videoSources, selectedVideo);
@@ -148,7 +211,7 @@ class UISlideOverlays {
audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); };
}
if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) {
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
menu.onOK.subscribe {
@@ -185,7 +248,7 @@ class UISlideOverlays {
}
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
val handleUnknownDownload: ()->Unit = {
showUnknownVideoDownload("Video", container) { px, bitrate ->
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate)
};
};
@@ -195,7 +258,7 @@ class UISlideOverlays {
val scope = StateApp.instance.scopeOrNull;
if(scope != null) {
val loader = showLoaderOverlay("Fetching video details", container);
val loader = showLoaderOverlay(container.context.getString(R.string.fetching_video_details), container);
scope.launch(Dispatchers.IO) {
try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
@@ -209,7 +272,7 @@ class UISlideOverlays {
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast("Failed to fetch details for download");
UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download));
handleUnknownDownload();
loader.hide(true);
}
@@ -220,7 +283,7 @@ class UISlideOverlays {
}
}
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload("Video", container) { px, bitrate ->
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(playlist, px, bitrate);
};
}
@@ -232,7 +295,7 @@ class UISlideOverlays {
var targetBitrate: Long = 0;
val resolutions = listOf(
Triple<String, String, Long>("None", "None", -1),
Triple<String, String, Long>(container.context.getString(R.string.none), container.context.getString(R.string.none), -1),
Triple<String, String, Long>("480P", "720x480", 720*480),
Triple<String, String, Long>("720P", "1280x720", 1280*720),
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
@@ -240,23 +303,23 @@ class UISlideOverlays {
Triple<String, String, Long>("2160P", "3840x2160", 3840*2160)
);
items.add(SlideUpMenuGroup(container.context, "Target Resolution", "Video", resolutions.map {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
targetPxSize = it.third;
menu?.selectOption("Video", it.third);
}, false)
}));
items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
targetBitrate = 1;
menu?.selectOption("Bitrate", 1);
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_movie, "High Bitrate", "", 9999999, {
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999);
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false)
)));
@@ -277,12 +340,12 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) {
targetBitrate = 9999999;
menu.selectOption("Bitrate", 9999999);
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
else {
targetBitrate = 1;
menu.selectOption("Bitrate", 1);
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
menu.onOK.subscribe {
@@ -304,14 +367,14 @@ class UISlideOverlays {
return overlay;
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -322,23 +385,23 @@ class UISlideOverlays {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, "Actions", "actions",
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container, true); }, false)
))
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))
+ actions)
));
items.add(
SlideUpMenuGroup(container.context, "Add To", "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue",
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "Add to " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} videos", "watch later",
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); })
));
val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "Add to " + playlist.name + "", "${playlist.videos.size} videos", "",
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -346,9 +409,9 @@ class UISlideOverlays {
}
if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems));
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Video Options", null, true, items).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.video_options), null, true, items).apply { show() };
}
@@ -360,8 +423,8 @@ class UISlideOverlays {
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -373,18 +436,18 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(
SlideUpMenuGroup(container.context, "Other", "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue",
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later",
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
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))
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} videos", "",
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -392,9 +455,9 @@ class UISlideOverlays {
}
if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems));
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Add to", null, true, items).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
}
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
@@ -412,8 +475,8 @@ class UISlideOverlays {
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn);
}, true) as View }.toTypedArray() ?: arrayOf(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "Decide which buttons should be pinned", "", {
showOrderOverlay(container, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null }
@@ -425,7 +488,7 @@ class UISlideOverlays {
}, false))
).flatten().toTypedArray();
return SlideUpMenuOverlay(container.context, container, "More Options", null, true, *views).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
}
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
@@ -433,7 +496,7 @@ class UISlideOverlays {
var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, "Save", true,
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second))
@@ -96,8 +96,8 @@ class AddSourceActivity : AppCompatActivity() {
var url = intent?.dataString;
if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
else {
if(url.startsWith("vfuto://"))
url = "https://" + url.substring("vfuto://".length);
@@ -129,14 +129,14 @@ class AddSourceActivity : AppCompatActivity() {
Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
"Invalid Config Format", null, null,
getString(R.string.invalid_config_format), null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
};
return@launch;
} catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex);
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_configuration), ex);
};
return@launch;
}
@@ -152,7 +152,7 @@ class AddSourceActivity : AppCompatActivity() {
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex);
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_script), ex);
};
return@launch;
}
@@ -175,8 +175,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView(
SourceInfoView(this,
R.drawable.ic_language,
"URL Access",
"The plugin will have access to the following domains",
getString(R.string.url_access),
getString(R.string.the_plugin_will_have_access_to_the_following_domains),
config.allowUrls, true)
)
@@ -184,8 +184,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView(
SourceInfoView(this,
R.drawable.ic_code,
"Eval Access",
"The plugin will have access to eval capability (remote injection)",
getString(R.string.eval_access),
getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
config.allowUrls, true)
)
@@ -16,13 +16,14 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton;
lateinit var _buttonPlugins: BigButton;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code")
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
@@ -31,7 +32,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, "Not a plugin URL")
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@let;
}
@@ -51,6 +52,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR = findViewById(R.id.option_qr);
_buttonURL = findViewById(R.id.option_url);
_buttonPlugins = findViewById(R.id.option_plugins);
_buttonBack.setOnClickListener {
finish();
@@ -59,7 +61,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR.onClick.subscribe {
val integrator = IntentIntegrator(this);
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR Code")
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
@@ -69,12 +71,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
_buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet..");
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
}
}
class QRCaptureActivity: CaptureActivity() {
}
}
@@ -38,8 +38,8 @@ class ExceptionActivity : AppCompatActivity() {
_buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?";
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
@@ -79,13 +79,13 @@ class ExceptionActivity : AppCompatActivity() {
private fun submitFile() {
if (_submitted) {
Toast.makeText(this, "Logs already submitted.", Toast.LENGTH_LONG).show();
Toast.makeText(this, getString(R.string.logs_already_submitted), Toast.LENGTH_LONG).show();
return;
}
val file = _file;
if (file == null) {
Toast.makeText(this, "No logs found.", Toast.LENGTH_LONG).show();
Toast.makeText(this, getString(R.string.no_logs_found), Toast.LENGTH_LONG).show();
return;
}
@@ -101,14 +101,14 @@ class ExceptionActivity : AppCompatActivity() {
withContext(Dispatchers.Main) {
if (id == null) {
try {
Toast.makeText(this@ExceptionActivity, "Failed automated share, share manually?", Toast.LENGTH_LONG).show();
Toast.makeText(this@ExceptionActivity, getString(R.string.failed_automated_share_share_manually), Toast.LENGTH_LONG).show();
} catch (e: Throwable) {
//Ignored
}
} else {
_submitted = true;
file.delete();
Toast.makeText(this@ExceptionActivity, "Shared $id", Toast.LENGTH_LONG).show();
Toast.makeText(this@ExceptionActivity, getString(R.string.shared_id).replace("{id}", id), Toast.LENGTH_LONG).show();
}
}
}
@@ -119,10 +119,10 @@ class ExceptionActivity : AppCompatActivity() {
val i = Intent(Intent.ACTION_SEND);
i.type = "text/plain";
i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org"));
i.putExtra(Intent.EXTRA_SUBJECT, "Unhandled exception in VS");
i.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.unhandled_exception_in_vs));
i.putExtra(Intent.EXTRA_TEXT, exceptionString);
startActivity(Intent.createChooser(i, "Send exception to developers..."));
startActivity(Intent.createChooser(i, getString(R.string.send_exception_to_developers)));
} catch (e: Throwable) {
//Ignored
@@ -459,6 +459,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "View Received: " + targetData);
}
}
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"TAB" -> {
when(intent.getStringExtra("TAB")){
"Sources" -> {
@@ -478,13 +482,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required.");
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
UIDialogs.toast("Invalid license format");
UIDialogs.toast(getString(R.string.invalid_license_format));
}
else if(targetData.startsWith("grayjay://plugin/")) {
@@ -499,7 +503,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown content format [${targetData}]",
getString(R.string.unknown_content_format) + " [${targetData}]",
"Ok",
{ });
}
@@ -509,7 +513,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown file format [${targetData}]",
getString(R.string.unknown_file_format) + " [${targetData}]",
"Ok",
{ });
}
@@ -519,7 +523,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown Polycentric format [${targetData}]",
getString(R.string.unknown_polycentric_format) + " [${targetData}]",
"Ok",
{ });
}
@@ -529,7 +533,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown url format [${targetData}]",
getString(R.string.unknown_url_format) + " [${targetData}]",
"Ok",
{ });
}
@@ -538,7 +542,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
catch(ex: Throwable) {
UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
}
}
@@ -603,7 +607,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore
else -> {
UIDialogs.toast("Unknown reconstruction type ${type}", false);
UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
return;
};
};
@@ -646,7 +650,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
catch(ex: Exception) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to parse NewPipe Subscriptions", ex);
UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
}
/*
@@ -925,5 +929,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getVideoIntent(context: Context, videoUrl: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
}
}
@@ -53,7 +53,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
_imageQR.setImageBitmap(qrCodeBitmap);
} catch (e: Exception) {
Logger.e(TAG, "Failed to generate QR code", e);
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
_imageQR.visibility = View.INVISIBLE;
_textQR.visibility = View.INVISIBLE;
}
@@ -63,12 +63,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
type = "text/plain";
putExtra(Intent.EXTRA_TEXT, _exportBundle);
}
startActivity(Intent.createChooser(shareIntent, "Share Text"));
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
};
_buttonCopy.onClick.subscribe {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
val clip = ClipData.newPlainText("Copied Text", _exportBundle);
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip);
};
}
@@ -54,7 +54,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try {
val username = _profileName.text.toString();
if (username.length < 3) {
UIDialogs.toast(this@PolycentricCreateProfileActivity, "Must be at least 3 characters long.");
UIDialogs.toast(this@PolycentricCreateProfileActivity, getString(R.string.must_be_at_least_3_characters_long));
return@setOnClickListener;
}
@@ -68,16 +68,18 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to create profile .", e);
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch;
} finally {
_creating = false;
}
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to fully backfill servers.");
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
}
withContext(Dispatchers.Main) {
@@ -47,7 +47,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt());
};
profileButton.withPrimaryText(systemState.username);
profileButton.withSecondaryText("Sign in to this identity");
profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
profileButton.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
@@ -59,7 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code")
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
@@ -70,7 +70,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, "Text field does not contain any data");
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener;
}
@@ -85,7 +85,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private fun import(url: String) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL");
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return;
}
@@ -101,7 +101,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
UIDialogs.toast(this, "This profile is already imported");
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return;
}
@@ -124,15 +124,11 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
finish();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, "Failed to import profile: '${e.message}'");
UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
}
companion object {
private const val TAG = "PolycentricImportProfileActivity";
}
class QRCaptureActivity: CaptureActivity() {
}
}
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CommentDialog
import com.futo.platformplayer.dp
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
@@ -72,7 +73,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to backfill client");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
}
}
@@ -101,10 +102,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
}
_buttonDelete.onClick.subscribe {
UIDialogs.showConfirmationDialog(this, "Are you sure you want to remove this profile?", {
UIDialogs.showConfirmationDialog(this, getString(R.string.are_you_sure_you_want_to_remove_this_profile), {
val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) {
UIDialogs.toast(this, "No process handle set");
UIDialogs.toast(this, getString(R.string.no_process_handle_set));
return@showConfirmationDialog;
}
@@ -122,13 +123,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false;
val username = _editName.text.toString();
if (username.length < 3) {
UIDialogs.toast(this@PolycentricProfileActivity, "Name must be at least 3 characters long");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
return@launch;
}
val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, "Process handle unset");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
return@launch;
}
@@ -143,7 +144,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri);
if (bytes == null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_read_image));
}
return@launch;
@@ -186,14 +187,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
if (hasChanges) {
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Changes have been saved");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to synchronize changes", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to synchronize changes");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_synchronize_changes));
}
}
}
@@ -235,7 +238,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
} else if (resultCode == ImagePicker.RESULT_ERROR) {
UIDialogs.toast(this, ImagePicker.getError(data));
} else {
UIDialogs.toast(this, "Image picker cancelled");
UIDialogs.toast(this, getString(R.string.image_picker_cancelled));
}
}
@@ -0,0 +1,7 @@
package com.futo.platformplayer.activities
import com.journeyapps.barcodescanner.CaptureActivity
class QRCaptureActivity : CaptureActivity() {
}
@@ -70,7 +70,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
};
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media
import androidx.collection.LruCache
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -49,6 +50,7 @@ class CachedPlatformClient : IPlatformClient {
return result;
}
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url);
override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url);
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -100,6 +101,8 @@ interface IPlatformClient {
*/
fun getContentDetails(url: String): IPlatformContentDetails;
fun getContentChapters(url: String): List<IChapter>;
/**
* Gets the playback tracker for a piece of content
*/
@@ -15,7 +15,8 @@ data class PlatformClientCapabilities(
val hasGetSearchCapabilities: Boolean = false,
val hasGetChannelCapabilities: Boolean = false,
val hasGetLiveEvents: Boolean = false,
val hasGetLiveChatWindow: Boolean = false
val hasGetLiveChatWindow: Boolean = false,
val hasGetContentChapters: Boolean = false
) {
}
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.models.chapters
import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter {
val name: String;
val type: ChapterType;
val timeStart: Int;
val timeEnd: Int;
}
enum class ChapterType(val value: Int) {
NORMAL(0),
SKIPPABLE(5),
SKIP(6);
companion object {
fun fromInt(value: Int): ChapterType
{
val result = ChapterType.values().firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;
}
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -181,6 +182,7 @@ open class JSClient : IPlatformClient {
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
);
try {
@@ -414,6 +416,17 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
}
@JSOptional //getContentChapters = function(url, initialData)
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
@JSDocsParameter("url", "A content url (this platform)")
override fun getContentChapters(url: String): List<IChapter> = isBusyWith {
if(!capabilities.hasGetContentChapters)
return@isBusyWith listOf();
ensureEnabled();
return@isBusyWith JSChapter.fromV8(config,
plugin.executeTyped("source.getContentChapters(${Json.encodeToString(url)})"));
}
@JSOptional
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
@JSDocsParameter("url", "A content url (this platform)")
@@ -568,6 +581,23 @@ open class JSClient : IPlatformClient {
};
}
fun resolveChannelUrlsByClaimTemplates(claimType: Int, values: Map<Int, String>): List<String> {
val urls = arrayListOf<String>();
channelClaimTemplates?.let {
if(it.containsKey(claimType)) {
val templates = it[claimType];
if(templates != null)
for(value in values.keys.sortedBy { it }) {
if(templates.containsKey(value)) {
urls.add(templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!));
}
}
}
};
return urls;
}
private fun <T> isBusyWith(handle: ()->T): T {
try {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.views.fields.FieldForm
@@ -66,15 +67,15 @@ class SourcePluginDescriptor {
@Serializable
class AppPluginSettings {
@FormField("Visibility", "group", "Enable where this plugin's content are visible.", 2)
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();
@Serializable
class TabEnabled {
@FormField("Home", FieldForm.TOGGLE, "Show content in home tab", 1)
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
var enableHome: Boolean? = null;
@FormField("Search", FieldForm.TOGGLE, "Show content in search results", 2)
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
var enableSearch: Boolean? = null;
}
@@ -0,0 +1,45 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter {
override val name: String;
override val type: ChapterType;
override val timeStart: Int;
override val timeEnd: Int;
constructor(name: String, timeStart: Int, timeEnd: Int, type: ChapterType = ChapterType.NORMAL) {
this.name = name;
this.timeStart = timeStart;
this.timeEnd = timeEnd;
this.type = type;
}
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject): IChapter {
val context = "Chapter";
val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Int>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Int>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type);
}
fun fromV8(config: IV8PluginConfig, arr: V8ValueArray): List<IChapter> {
return arr.keys.mapNotNull {
val obj = arr.get<V8ValueObject>(it);
return@mapNotNull fromV8(config, obj);
};
}
}
}
@@ -75,7 +75,12 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
return toReturn;
}
private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean {
return item.name == item2.name && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < 2);
//return item == item2;
val daysAgo = Math.abs(item.datetime?.getNowDiffDays() ?: return false);
val maxDelta = Math.max(2, (daysAgo / 1.5).toInt()); //TODO: Better scaling delta
val isSame = item.name.equals(item2.name, true) && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < maxDelta);
return isSame;
}
private fun calculateHash(item: IPlatformContent): Int {
return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode()));
@@ -8,6 +8,7 @@ import java.util.stream.IntStream
*/
class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {}
constructor(pagers : List<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers, allowFailure, pageSize) {}
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.runBlocking
import java.util.stream.IntStream
/**
* A Content AsyncMultiPager that returns results based on a specified distribution
* Unlike its non-async counterpart, this one uses parallel nextPage requests
*/
class MultiChronoContentParallelPager : MultiParallelPager<IPlatformContent> {
constructor(pagers: List<IPager<IPlatformContent>>) : super(pagers)
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
if(options.size == 0)
return -1;
var bestIndex = 0;
val allResults = runBlocking { options.map { Pair(it, it.item?.await()) } };
for(i in IntStream.range(1, options.size)) {
val best = allResults[bestIndex].second;
val cur = allResults[i].second ?: continue;
if(best?.datetime == null || (cur.datetime != null && cur.datetime!! > best.datetime!!))
bestIndex = i;
}
return bestIndex;
}
}
@@ -66,25 +66,25 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_pagersReusable.add((PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>).asReusable());
_currentPager = recreatePager(getCurrentSubPagers());
if(_currentPager is MultiParallelPager<*>)
runBlocking { (_currentPager as MultiParallelPager).initialize(); };
else if(_currentPager is MultiPager<*>)
(_currentPager as MultiPager).initialize()
onPagerChanged.emit(_currentPager);
}
return;
}
synchronized(_pagersReusable) {
if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_pagersReusable.add((PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>).asReusable());
_currentPager = recreatePager(getCurrentSubPagers());
if(_currentPager is MultiParallelPager<*>)
runBlocking { (_currentPager as MultiParallelPager).initialize(); };
else if(_currentPager is MultiPager<*>)
(_currentPager as MultiPager).initialize()
onPagerChanged.emit(_currentPager);
}
return;
}
Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
_pagersReusable.add(pagerToAdd.asReusable());
@@ -0,0 +1,19 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.Deferred
/**
* A RefreshMultiPager that simply returns all respective pagers in equal distribution, optionally inserting PlaceholderPager results as provided for their respective promised pagers
* (Eg. Pager A is completed, Pager [B,C,D] are promised/deferred. placeholderPagers [1,2,3] will map B=>1, C=>2, D=>3 until promised pagers are completed)
* Uses wrapped MultiDistributionContentAsyncPager for inidivual pagers.
*/
class RefreshChronoContentPager(pagers: List<IPager<IPlatformContent>>, pendingPagers: List<Deferred<IPager<IPlatformContent>?>>, placeholderPagers: List<IPager<IPlatformContent>>? = null)
: MultiRefreshPager<IPlatformContent>(pagers, pendingPagers, placeholderPagers) {
override fun recreatePager(pagers: List<IPager<IPlatformContent>>): IPager<IPlatformContent> {
return MultiChronoContentPager(pagers);
//return MultiChronoContentParallelPager(pagers);
//return MultiDistributionContentPager(pagers.associateWith { 1f });
}
}
@@ -43,6 +43,7 @@ class SingleAsyncItemPager<T> {
if (_currentResultPos >= _requestedPageItems.size) {
val startPos = fillDeferredUntil(_currentResultPos);
if(!_pager.hasMorePages()) {
Logger.i("SingleAsyncItemPager", "end of async page reached");
completeRemainder { it?.complete(null) };
}
if(_isRequesting)
@@ -4,12 +4,14 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.media.MediaSession2Service.MediaNotification
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.concurrent.futures.ResolvableFuture
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
@@ -83,6 +85,8 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
val newSubChanges = hashSetOf<Subscription>();
val newItems = mutableListOf<IPlatformContent>();
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
withContext(Dispatchers.IO) {
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
@@ -97,8 +101,11 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
}
}, { sub, content ->
synchronized(newSubChanges) {
if(!newSubChanges.contains(sub))
if(!newSubChanges.contains(sub)) {
newSubChanges.add(sub);
if(sub.doNotifications)
contentNotifs.add(Pair(sub, content));
}
newItems.add(content);
}
});
@@ -106,12 +113,31 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
manager.cancel(12);
if(newItems.size > 0)
if(newItems.size > 0) {
try {
val items = contentNotifs.take(5).toList()
for(i in items.indices) {
val contentNotif = items.get(i);
manager.notify(13 + i, NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("New video by [${contentNotif.first.channel.name}]")
.setContentText("${contentNotif.second.name}")
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, contentNotif.second.url),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setChannelId(notificationChannel.id).build());
}
}
catch(ex: Throwable) {
Logger.e("BackgroundWorker", "Failed to create notif", ex);
}
}
/*
manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("Grayjay")
.setContentText("${newItems.size} new content from ${newSubChanges.size} creators")
.setSilent(true)
.setChannelId(notificationChannel.id).build());
.setChannelId(notificationChannel.id).build());*/
}
}
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.states.StateApp
@@ -352,16 +353,25 @@ class StateCasting {
}
}
} else {
if (videoSource is IVideoUrlSource) {
if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
} else if (audioSource is IAudioUrlSource) {
else if(videoSource is IHLSManifestSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
else if (audioSource is IAudioUrlSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
} else if (videoSource is LocalVideoSource) {
else if(audioSource is IHLSManifestAudioSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
else if (videoSource is LocalVideoSource)
castLocalVideo(video, videoSource, resumePosition);
} else if (audioSource is LocalAudioSource) {
else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition);
} else {
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource");
else {
var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
).filterNotNull().joinToString(", ");
throw UnsupportedCastException(str);
}
}
@@ -6,6 +6,7 @@ import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
@@ -90,7 +91,9 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers()
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e);
}
@@ -0,0 +1,6 @@
package com.futo.platformplayer.exceptions
import java.lang.Exception
class UnsupportedCastException(msg: String) : Exception(msg) {
}
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
};
_textName?.text = channel.name;
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + (context?.getString(R.string.subscribers)?.lowercase() ?: "") else "";
_textMetadata?.text = metadata;
_lastChannel = channel;
setLinks(channel.links, channel.name);
@@ -91,7 +91,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
l.removeAllViews();
if (links.isNotEmpty()) {
_textFindOn?.text = "Find $name on";
_textFindOn?.text = getString(R.string.find_name_on).replace("{name}", name);
_textFindOn?.visibility = View.VISIBLE;
for (pair in links) {
@@ -248,7 +248,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager is IReplacerPager<*>)
(_pager as IReplacerPager<*>).onReplaced.remove(this);
if(pager is IReplacerPager<*>) {
pager.onReplaced.subscribe(this) { oldItem, newItem ->
if(_pager != pager)
@@ -257,11 +256,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager !is IPager<IPlatformContent>)
return@subscribe;
val toReplaceIndex = _results.indexOfFirst { it == newItem };
if(toReplaceIndex >= 0) {
_results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
lifecycleScope.launch(Dispatchers.Main) {
val toReplaceIndex = _results.indexOfFirst { it == oldItem };
if (toReplaceIndex >= 0) {
_results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
}
}
}
}
@@ -56,7 +56,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
}.exception<ScriptCaptchaRequiredException> { }
.exceptionWithParameter<Throwable> { ex, para ->
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false)
UIDialogs.toast(requireContext(), getString(R.string.failed_to_fetch) + "\n " + para, false)
loadNext();
};
@@ -220,6 +220,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttons.removeAt(buyIndex)
buttons.add(0, button)
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(1, button)
}
for (data in buttons) {
val button = MenuButton(context, data, _fragment, true);
@@ -289,6 +296,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
}
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.faq, canToggle = false, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
}))
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -60,12 +60,12 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, "Payment succeeded", "Thanks for your purchase, a key will be sent to your email after your payment has been received!", null, 0,
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
}
else {
UIDialogs.showGeneralErrorDialog(context, "Payment failed", exception);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
}
}
@@ -107,12 +107,12 @@ class BuyFragment : MainFragment() {
}
private fun paid() {
val licenseInput = SlideUpMenuTextInput(context, "License");
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), "Enter license key", "Ok", true, licenseInput);
val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
productLicenseDialog.onOK.subscribe {
val licenseText = licenseInput.text;
if (licenseText.isNullOrEmpty()) {
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key");
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
return@subscribe;
}
@@ -127,19 +127,19 @@ class BuyFragment : MainFragment() {
licenseInput.clear();
productLicenseDialog.hide(true);
UIDialogs.showDialogOk(context, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required.");
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
_fragment.close(true);
}
else
{
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key");
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
}
}
}
catch(ex: Throwable) {
Logger.e("BuyFragment", "Failed to activate key", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to activate key", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_activate_key), ex);
}
}
}
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -41,6 +42,7 @@ import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
@@ -100,6 +102,7 @@ class ChannelFragment : MainFragment() {
private var _viewPager: ViewPager2;
private var _tabLayoutMediator: TabLayoutMediator;
private var _buttonSubscribe: SubscribeButton;
private var _buttonSubscriptionSettings: ImageButton;
private var _overlayContainer: FrameLayout;
private var _overlay_loading: LinearLayout;
@@ -141,7 +144,7 @@ class ChannelFragment : MainFragment() {
UIDialogs.showDialog(context,
R.drawable.ic_sources,
"No source enabled to support this channel\n(${_url})", null, null,
context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", null, null,
0,
UIDialogs.Action("Back", {
fragment.close(true);
@@ -160,10 +163,21 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_imageBanner = findViewById(R.id.image_channel_banner);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
_overlay_loading = findViewById(R.id.channel_loading_overlay);
_overlay_loading_spinner = findViewById(R.id.channel_loader);
_overlayContainer = findViewById(R.id.overlay_container);
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
}
_buttonSubscriptionSettings.setOnClickListener {
val url = channel?.url ?: _url ?: return@setOnClickListener;
val sub = StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener;
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer);
};
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false;
viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
@@ -246,28 +260,46 @@ class ChannelFragment : MainFragment() {
if (parameter is String) {
_buttonSubscribe.setSubscribeChannel(parameter);
_textChannel.text = "";
_textChannelSub.text = "";
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
setPolycentricProfileOr(parameter) {
_textChannel.text = "";
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(null, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
};
_url = parameter;
loadChannel();
} else if (parameter is SerializedChannel) {
showChannel(parameter);
_url = parameter.url;
_creatorThumbnail.setThumbnail(parameter.url, false);
loadChannel();
} else if (parameter is IPlatformChannel)
showChannel(parameter);
else if (parameter is PlatformAuthorLink) {
_textChannel.text = parameter.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.url, false);
setPolycentricProfileOr(parameter.url) {
_textChannel.text = parameter.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.id);
};
_url = parameter.url;
loadChannel();
} else if (parameter is Subscription) {
_textChannel.text = parameter.channel.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false);
setPolycentricProfileOr(parameter.channel.url) {
_textChannel.text = parameter.channel.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.channel.id);
};
_url = parameter.channel.url;
loadChannel();
@@ -327,19 +359,19 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.onShown(channel);
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
UIDialogs.showConfirmationDialog(context, "Do you want to convert channel ${channel.name} to a playlist?", {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist).replace("{channelName}", channel.name), {
UIDialogs.showDialogProgress(context) {
_fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
_fragment.lifecycleScope.launch(Dispatchers.Main) {
it.setText("${channel.name}\nPage ${page}");
it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page");
}
};
}
catch(ex: Exception) {
Logger.e(TAG, "Error", ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to convert channel", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_convert_channel), ex);
}
withContext(Dispatchers.Main) {
@@ -360,14 +392,8 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner)
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
//TODO: Find a better way to access the adapter fragments..
@@ -381,51 +407,68 @@ class ChannelFragment : MainFragment() {
this.channel = channel;
val cachedProfile = PolycentricCache.instance.getCachedProfile(channel.url);
setPolycentricProfileOr(channel.url) {
_textChannel.text = channel.name;
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner);
_taskLoadPolycentricProfile.run(channel.id);
};
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false);
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(channel.id);
or();
}
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
val polycentricProfile = cachedPolycentricProfile?.profile;
if (polycentricProfile != null) {
_fragment.topBar?.onShown(polycentricProfile);
val dp_35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (polycentricProfile.systemState.username.isNotBlank())
_textChannel.text = polycentricProfile.systemState.username;
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
val dp_35 = 35.dp(resources)
val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, true);
} else {
_creatorThumbnail.setHarborAvailable(true, true);
}
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
} else {
Glide.with(_imageBanner)
.load(channel?.banner)
.crossfade()
.into(_imageBanner);
}
val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage()
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
}
if (profile != null) {
_fragment.topBar?.onShown(profile);
_textChannel.text = profile.systemState.username;
}
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile, animate);
//TODO: Call on other tabs as needed
}
}
@@ -15,7 +15,9 @@ import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
@@ -24,6 +26,7 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -69,22 +72,31 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
//TODO: Reconstruct search video from detail if search is null
_overlayContainer.let {
if(content is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(content, it) {
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}
};
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
};
adapter.onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it);
val name = if (it.name.length > 20) (it.name.subSequence(0, 20).toString() + "...") else it.name;
UIDialogs.toast(context, "Queued [$name]", false);
UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
}
};
}
@@ -89,7 +89,7 @@ class ContentSearchResultsFragment : MainFragment() {
})
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
Logger.w(TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
}
}
@@ -101,14 +101,12 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is SuggestionsFragmentData) {
if(!isBack) {
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
this.setText(parameter.query);
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
this.setText(parameter.query);
}
}
}
@@ -71,16 +71,14 @@ class CreatorSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) {
if(!isBack) {
setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
}
}
@@ -136,8 +136,8 @@ class DownloadsFragment : MainFragment() {
fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} Used";
_usageAvailable.text = "${usage.available.toHumanBytesSize()} Available";
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
_usageAvailable.text = "${usage.available.toHumanBytesSize()} " + context.getString(R.string.available);
_usageProgress.progress = usage.percentage.toFloat();
@@ -161,7 +161,7 @@ class DownloadsFragment : MainFragment() {
_listPlaylistsContainer.visibility = GONE;
else {
_listPlaylistsContainer.visibility = VISIBLE;
_listPlaylistsMeta.text = "(${playlists.size} playlists, ${playlists.sumOf { it.playlist.videos.size }} videos)";
_listPlaylistsMeta.text = "(${playlists.size} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size }} ${context.getString(R.string.videos).lowercase()})";
_listPlaylists.removeAllViews();
for(view in playlists.map { PlaylistDownloadItem(context, it) }) {
@@ -176,7 +176,7 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE;
} else {
_listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} videos)";
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
}
_listDownloaded.setData(downloaded);
@@ -144,7 +144,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load next page", it, {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
loadNextPage();
});
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
@@ -256,7 +256,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if(jsVideoPager != null)
UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false);
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", jsVideoPager.getPluginConfig().name).replace("{message}", kv.value.message ?: ""), false);
else
UIDialogs.toast(it, kv.value.message ?: "", false);
} catch (e: Throwable) {
@@ -333,11 +333,11 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
parentPager.onPagerError.subscribe(this) {
Logger.e(TAG, "Search pager failed: ${it.message}", it);
when (it) {
is PluginException -> UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}")
is PluginException -> UIDialogs.toast("Plugin [{pluginName}] failed due to:\n{exceptionMessage}".replace("{pluginName}", it.config.name).replace("{exceptionMessage}", it.message ?: ""))
is CancellationException -> {
//Hide cancelled toast
}
else -> UIDialogs.toast("Plugin failed due to:\n${it.message}")
else -> UIDialogs.toast(context.getString(R.string.plugin_failed_due_to) + "\n${it.message}")
};
};
}
@@ -101,21 +101,21 @@ class HomeFragment : MainFragment() {
.exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0,
UIDialogs.Action("Ignore", {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.failed_to_get_home_plugin) + " [${it.config.name}]", it.message, null, 0,
UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
);
}
.exception<ScriptImplementationException> {
Logger.w(TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0,
UIDialogs.Action("Ignore", {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.failed_to_get_home_plugin) + " [${it.config.name}]", it.message, null, 0,
UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
);
}
.exception<Throwable> {
Logger.w(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_get_home), it, {
loadResults()
}) {
finishRefreshLayoutLoader();
@@ -159,7 +159,7 @@ class HomeFragment : MainFragment() {
}
private fun loadedResult(pager : IPager<IPlatformContent>) {
if (pager is EmptyPager<IPlatformContent>) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "No home available", "No home page is available, please check if you are connected to the internet and refresh.", AnnouncementType.SESSION);
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION);
}
Logger.i(TAG, "Got new home pager ${pager}");
@@ -113,7 +113,7 @@ class ImportPlaylistsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(context, "Failed to fetch\n${para}", false)
UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false)
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext();
};
@@ -144,14 +144,14 @@ class ImportPlaylistsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let {
it.title = "Import Playlists";
it.title = context.getString(R.string.import_playlists);
it.onImport.subscribe(this) {
val playlistsToImport = _items.filter { i -> i.selected }.toList();
for (playlistToImport in playlistsToImport) {
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist);
}
UIDialogs.toast("${playlistsToImport.size} playlists imported.");
UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
_fragment.closeSegment();
};
}
@@ -175,7 +175,7 @@ class ImportPlaylistsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all);
_textCounter.text = "$itemsSelected out of ${_items.size} selected";
_textCounter.text = context.getString(R.string.index_out_of_size_selected).replace("{index}", itemsSelected.toString()).replace("{size}", _items.size.toString());
(_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else {
_textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -116,7 +116,7 @@ class ImportSubscriptionsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(context, "Failed to fetch\n${para}", false)
UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false)
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext();
};
@@ -147,14 +147,14 @@ class ImportSubscriptionsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let {
it.title = "Import Subscriptions";
it.title = context.getString(R.string.import_subscriptions);
it.onImport.subscribe(this) {
val subscriptionsToImport = _items.filter { i -> i.selected }.toList();
for (subscriptionToImport in subscriptionsToImport) {
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
}
UIDialogs.toast("${subscriptionsToImport.size} subscriptions imported.");
UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
_fragment.closeSegment();
};
}
@@ -165,7 +165,7 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) {
_limitToastShown = true;
UIDialogs.toast(context, "Stopped after $MAXIMUM_BATCH_SIZE to avoid rate limit, re-enter to import rest");
UIDialogs.toast(context, "Stopped after {requestCount} to avoid rate limit, re-enter to import rest".replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
}
setLoading(false);
@@ -187,7 +187,7 @@ class ImportSubscriptionsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all);
_textCounter.text = "$itemsSelected out of ${_items.size} selected";
_textCounter.text = context.getString(R.string.index_out_of_size_selected).replace("{index}", itemsSelected.toString()).replace("{size}", _items.size.toString());
(_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else {
_textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -80,8 +80,8 @@ class PlaylistFragment : MainFragment() {
constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) {
_fragment = fragment;
val nameInput = SlideUpMenuTextInput(context, "Name");
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, "Edit playlist", "Ok", false, nameInput);
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
_buttonDownload.visibility = View.VISIBLE;
editPlaylistOverlay.onOK.subscribe {
@@ -113,14 +113,14 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return@setOnShare;
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
UISlideOverlays.showOverlay(overlayContainer, "Playlist [${playlist.name}]", null, {},
SlideUpMenuItem(context, R.drawable.ic_list, "Share as Text", "Share as a list of video urls", 1, {
UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, {
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("text/plain")
.setText(reconstruction)
.intent);
}),
SlideUpMenuItem(context, R.drawable.ic_move_up, "Share as Import", "Share as a import file for Grayjay", 2, {
SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, {
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("application/json")
@@ -146,7 +146,7 @@ class PlaylistFragment : MainFragment() {
.exception<Throwable> {
Logger.w(TAG, "Failed to load playlist.", it);
val c = context ?: return@exception;
UIDialogs.showGeneralRetryErrorDialog(c, "Failed to load playlist", it, ::fetchPlaylist);
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist);
};
}
@@ -234,7 +234,7 @@ class PlaylistFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
val remotePlaylist = _remotePlaylist;
if (remotePlaylist == null) {
UIDialogs.toast("Please wait for playlist to finish loading");
UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading));
return@Pair;
}
@@ -245,7 +245,7 @@ class PlaylistFragment : MainFragment() {
withContext(Dispatchers.Main) {
setLoading(false);
UIDialogs.toast("Playlist copied as local playlist");
UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist));
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
@@ -284,7 +284,7 @@ class PlaylistFragment : MainFragment() {
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete the downloaded videos?", {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
});
}
@@ -292,7 +292,7 @@ class PlaylistFragment : MainFragment() {
else if(isDownloaded) {
_buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete the downloaded videos?", {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
});
}
@@ -73,16 +73,14 @@ class PlaylistSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) {
if(!isBack) {
setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
}
}
@@ -92,8 +92,8 @@ class PlaylistsFragment : MainFragment() {
recyclerPlaylists.adapter = _adapterPlaylist;
recyclerPlaylists.layoutManager = LinearLayoutManager(context);
val nameInput = SlideUpMenuTextInput(context, "Name");
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), "Create new playlist", "Ok", false, nameInput);
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
_adapterPlaylist.onPlay.subscribe { p ->
@@ -130,7 +130,7 @@ class PlaylistsFragment : MainFragment() {
_appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist);
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>("Watch Later"); };
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
updateWatchLater();
};
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -154,13 +155,13 @@ class PostDetailFragment : MainFragment {
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformPostDetails)
throw IllegalStateException("Expected media content, found ${result.contentType}");
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setPostDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load post.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load post", it, ::fetchPost);
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) })
@@ -210,6 +211,11 @@ class PostDetailFragment : MainFragment {
_repliesOverlay = findViewById(R.id.replies_overlay);
_buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop);
@@ -222,7 +228,7 @@ class PostDetailFragment : MainFragment {
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount replies";
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
@@ -357,7 +363,9 @@ class PostDetailFragment : MainFragment {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
@@ -601,7 +609,7 @@ class PostDetailFragment : MainFragment {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE;
_channelMeta.text = if((value.author?.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " subscribers" else "";
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
} else {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
@@ -63,6 +63,7 @@ class SourceDetailFragment : MainFragment() {
private val _sourceHeader: SourceHeaderView;
private val _sourceButtons: LinearLayout;
private val _sourceAdvancedButtons: LinearLayout;
private val _layoutLoader: FrameLayout;
private val _imageSpinner: ImageView;
@@ -82,6 +83,7 @@ class SourceDetailFragment : MainFragment() {
this.fragment = fragment;
_sourceHeader = findViewById(R.id.source_header);
_sourceButtons = findViewById(R.id.source_buttons);
_sourceAdvancedButtons = findViewById(R.id.advanced_source_buttons);
_settingsAppForm = findViewById(R.id.source_app_setings);
_settingsForm = findViewById(R.id.source_settings);
_layoutLoader = findViewById(R.id.layout_loader);
@@ -107,7 +109,7 @@ class SourceDetailFragment : MainFragment() {
StatePlugins.instance.setPluginSettings(id, _settings!!);
reloadSource(id);
UIDialogs.toast("Plugin settings saved", false);
UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false);
}
if(_settingsAppChanged) {
_settingsAppForm.setObjectValues();
@@ -144,8 +146,8 @@ class SourceDetailFragment : MainFragment() {
try {
_settings = settingValues;
_settingsForm.fromPluginSettings(
settings, settingValues, "Plugin settings",
"These settings are defined by the plugin"
settings, settingValues, context.getString(R.string.plugin_settings),
context.getString(R.string.these_settings_are_defined_by_the_plugin)
);
_settingsForm.onChanged.clear();
_settingsForm.onChanged.subscribe { field, value ->
@@ -158,7 +160,7 @@ class SourceDetailFragment : MainFragment() {
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to load source", ex);
UIDialogs.toast("Failed to loast source");
UIDialogs.toast(context.getString(R.string.failed_to_load_source));
}
}
}
@@ -204,8 +206,8 @@ class SourceDetailFragment : MainFragment() {
val isEnabled = StatePlatform.instance.isClientEnabled(source);
groups.add(
BigButtonGroup(c, "Update",
BigButton(c, "Check for updates", "Checks for new versions of the source", R.drawable.ic_update) {
BigButtonGroup(c, context.getString(R.string.update),
BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) {
checkForUpdatesSource();
}
)
@@ -213,8 +215,8 @@ class SourceDetailFragment : MainFragment() {
if (source.isLoggedIn) {
groups.add(
BigButtonGroup(c, "Authentication",
BigButton(c, "Logout", "Sign out of the platform", R.drawable.ic_logout) {
BigButtonGroup(c, context.getString(R.string.authentication),
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource();
}
)
@@ -223,7 +225,7 @@ class SourceDetailFragment : MainFragment() {
val migrationButtons = mutableListOf<BigButton>();
if (isEnabled && source.capabilities.hasGetUserSubscriptions) {
migrationButtons.add(
BigButton(c, "Import Subscriptions", "Import your subscriptions from this source", R.drawable.ic_subscriptions) {
BigButton(c, context.getString(R.string.import_subscriptions), context.getString(R.string.import_your_subscriptions_from_this_source), R.drawable.ic_subscriptions) {
Logger.i(TAG, "Import subscriptions clicked.");
importSubscriptionsSource();
}
@@ -231,7 +233,7 @@ class SourceDetailFragment : MainFragment() {
}
if (isEnabled && source.capabilities.hasGetUserPlaylists && source.capabilities.hasGetPlaylist) {
val bigButton = BigButton(c, "Import Playlists", "Import your playlists from this source", R.drawable.ic_playlist) {
val bigButton = BigButton(c, context.getString(R.string.import_playlists), context.getString(R.string.import_your_playlists_from_this_source), R.drawable.ic_playlist) {
Logger.i(TAG, "Import playlists clicked.");
importPlaylistsSource();
};
@@ -244,13 +246,13 @@ class SourceDetailFragment : MainFragment() {
}
if (migrationButtons.size > 0) {
groups.add(BigButtonGroup(c, "Migration", *migrationButtons.toTypedArray()));
groups.add(BigButtonGroup(c, context.getString(R.string.migration), *migrationButtons.toTypedArray()));
}
} else {
if(config.authentication != null) {
groups.add(
BigButtonGroup(c, "Authentication",
BigButton(c, "Login", "Sign into the platform of this source", R.drawable.ic_login) {
BigButtonGroup(c, context.getString(R.string.authentication),
BigButton(c, context.getString(R.string.login), context.getString(R.string.sign_into_the_platform_of_this_source), R.drawable.ic_login) {
loginSource();
}
)
@@ -260,8 +262,8 @@ class SourceDetailFragment : MainFragment() {
val clientIfExists = StatePlugins.instance.getPlugin(config.id);
groups.add(
BigButtonGroup(c, "Management",
BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) {
BigButtonGroup(c, context.getString(R.string.management),
BigButton(c, context.getString(R.string.uninstall), context.getString(R.string.removes_the_plugin_from_the_app), R.drawable.ic_block) {
uninstallSource();
}.withBackground(R.drawable.background_big_button_red).apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
@@ -269,8 +271,8 @@ class SourceDetailFragment : MainFragment() {
};
},
if(clientIfExists?.captchaEncrypted != null)
BigButton(c, "Delete Captcha", "Deletes stored captcha answer for this plugin", R.drawable.ic_block) {
clientIfExists?.updateCaptcha(null);
BigButton(c, context.getString(R.string.delete_captcha), context.getString(R.string.deletes_stored_captcha_answer_for_this_plugin), R.drawable.ic_block) {
clientIfExists.updateCaptcha(null);
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
@@ -283,6 +285,17 @@ class SourceDetailFragment : MainFragment() {
for (group in groups) {
_sourceButtons.addView(group);
}
val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
}.apply {
this.alpha = 0.5f;
}
)
_sourceAdvancedButtons.removeAllViews();
_sourceAdvancedButtons.addView(advancedButtons);
}
@@ -329,7 +342,7 @@ class SourceDetailFragment : MainFragment() {
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve playlists.", e) }
context?.let { UIDialogs.showGeneralErrorDialog(it, it.getString(R.string.failed_to_retrieve_playlists), e) }
}
} finally {
setLoading(false);
@@ -354,14 +367,14 @@ class SourceDetailFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val subscriptions = source.getUserSubscriptions().distinct();
Logger.i(TAG, "${subscriptions.size} user subscriptions retrieved.");
Logger.i(TAG, context.getString(R.string.subscriptioncount_user_subscriptions_retrieved).replace("{subscriptionCount}", subscriptions.size.toString()));
withContext(Dispatchers.Main) {
fragment.navigate<ImportSubscriptionsFragment>(subscriptions);
}
} catch(e: Throwable) {
withContext(Dispatchers.Main) {
context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve subscriptions.", e) }
context?.let { UIDialogs.showGeneralErrorDialog(it, context.getString(R.string.failed_to_retrieve_subscriptions), e) }
}
} finally {
setLoading(false);
@@ -375,7 +388,7 @@ class SourceDetailFragment : MainFragment() {
val config = _config ?: return;
val source = StatePlatform.instance.getClient(config.id);
UIDialogs.showConfirmationDialog(context, "Are you sure you want to uninstall ${source.name}", {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_uninstall) + " ${source.name}", {
StatePlugins.instance.deletePlugin(source.id);
fragment.lifecycleScope.launch(Dispatchers.IO) {
@@ -386,7 +399,7 @@ class SourceDetailFragment : MainFragment() {
}
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Uninstalled ${source.name}");
UIDialogs.toast(context, context.getString(R.string.uninstalled) + " ${source.name}");
fragment.closeSegment();
}
}
@@ -405,7 +418,7 @@ class SourceDetailFragment : MainFragment() {
if (!response.isOk || response.body == null) {
Logger.w(TAG, "Failed to check for updates (sourceUrl=${sourceUrl}, response.isOk=${response.isOk}, response.body=${response.body}).");
withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); };
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.failed_to_check_for_updates)); };
return@launch;
}
@@ -415,7 +428,7 @@ class SourceDetailFragment : MainFragment() {
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
Logger.i(TAG, "Plugin is up to date.");
withContext(Dispatchers.Main) { UIDialogs.toast("Plugin is fully up to date"); };
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
return@launch;
}
@@ -430,7 +443,7 @@ class SourceDetailFragment : MainFragment() {
Logger.i(TAG, "Started add source activity.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); };
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.failed_to_check_for_updates)); };
}
}
}
@@ -195,9 +195,8 @@ class SubscriptionsFeedFragment : MainFragment() {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_security_pred,
"Rate Limit Warning", "This is a temporary measure to prevent people from hitting rate limit until we have better support for lots of subscriptions." +
"\n\nYou have too many subscriptions for the following plugins:\n",
subsByLimited.map { "${it.first.config.name}: ${it.second.size} Subscriptions" } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
context.getString(R.string.rate_limit_warning), context.getString(R.string.this_is_a_temporary_measure_to_prevent_people_from_hitting_rate_limit_until_we_have_better_support_for_lots_of_subscriptions) + context.getString(R.string.you_have_too_many_subscriptions_for_the_following_plugins),
subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
_bypassRateLimit = true;
loadResults();
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
@@ -226,10 +225,10 @@ class SubscriptionsFeedFragment : MainFragment() {
synchronized(_filterLock) {
_subscriptionBar?.setToggles(
SubscriptionBar.Toggle("Videos", _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
SubscriptionBar.Toggle("Posts", _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle("Live", _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle("Planned", _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }
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); }
);
}
@@ -283,7 +282,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
val results = cachePager.getResults();
Logger.i(TAG, "Subscription show cache (${results.size})");
setTextCentered(if (results.isEmpty()) "No results found\nSwipe down to refresh" else null);
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setPager(cachePager);
} else {
setTextCentered(null);
@@ -299,7 +298,7 @@ class SubscriptionsFeedFragment : MainFragment() {
finishRefreshLayoutLoader();
setLoading(false);
setPager(pager);
setTextCentered(if (pager.getResults().isEmpty()) "No results found\nSwipe down to refresh" else null);
setTextCentered(if (pager.getResults().isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to finish loading", e)
}
@@ -326,7 +325,7 @@ class SubscriptionsFeedFragment : MainFragment() {
if (toShow is PluginException)
UIDialogs.toast(
it,
"Plugin [${toShow.config.name}] (${channel}) failed:\n${toShow.message}"
context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", toShow.config.name).replace("{message}", toShow.message ?: "")
);
else
UIDialogs.toast(it, ex.message ?: "");
@@ -340,7 +339,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.map { it!! }
.toList();
for(distinctPluginFail in failedPlugins)
UIDialogs.toast(it, "Plugin [${distinctPluginFail.config.name}] failed:\n${distinctPluginFail.message}");
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle exceptions", e)
@@ -62,6 +62,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -313,6 +314,11 @@ class VideoDetailView : ConstraintLayout {
_layoutMonetization.visibility = View.GONE;
_player.attachPlayer();
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
_container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl);
@@ -540,7 +546,7 @@ class VideoDetailView : ConstraintLayout {
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount replies";
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
@@ -578,13 +584,13 @@ class VideoDetailView : ConstraintLayout {
}
fun updateMoreButtons() {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, "Add", TAG_ADD) {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer);
}
},
if(video?.isLive ?: false)
RoundButton(context, R.drawable.ic_chat, "Live Chat", TAG_LIVECHAT) {
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
video?.let {
try {
loadLiveChat(it);
@@ -594,7 +600,7 @@ class VideoDetailView : ConstraintLayout {
}
}
} else null,
RoundButton(context, R.drawable.ic_screen_share, "Background", TAG_BACKGROUND) {
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
@@ -606,31 +612,31 @@ class VideoDetailView : ConstraintLayout {
it.text.text = resources.getString(R.string.background);
}
},
RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) {
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
};
},
RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) {
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true;
shareVideo();
};
},
RoundButton(context, R.drawable.ic_screen_share, "Overlay", TAG_OVERLAY) {
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
},
RoundButton(context, R.drawable.ic_export, "Page", TAG_OPEN) {
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail();
};
},
RoundButton(context, R.drawable.ic_refresh, "Reload", "Reload") {
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo();
}).filterNotNull();
if(!_buttonPinStore.getAllValues().any())
@@ -856,7 +862,7 @@ class VideoDetailView : ConstraintLayout {
val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0)
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}");
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) context.getString(R.string.watching_now) else context.getString(R.string.views)}");
if(video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0;
val ago = video.datetime?.toHumanNowDiffString(true)
@@ -870,10 +876,9 @@ class VideoDetailView : ConstraintLayout {
_commentsList.clear();
_platform.setPlatformFromClientID(video.id.pluginId);
_subTitle.text = subTitleSegments.joinToString("");
_channelName.text = video.author.name;
_playWhenReady = true;
if(video.author.subscribers != null) {
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers)else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else {
_channelMeta.text = "";
@@ -897,6 +902,7 @@ class VideoDetailView : ConstraintLayout {
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
_channelName.text = video.author.name;
}
_player.clear();
@@ -932,7 +938,7 @@ class VideoDetailView : ConstraintLayout {
}
if(videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}")
UIDialogs.toast(context, context.getString(R.string.planned_in) + " ${videoDetail.datetime?.toHumanNowDiffString(true)}")
if (!videoDetail.isLive) {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
@@ -948,6 +954,17 @@ class VideoDetailView : ConstraintLayout {
if(video is JSVideoDetails) {
val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
//TODO: Implement video.getContentChapters()
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
}
}
try {
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker()
@@ -964,7 +981,7 @@ class VideoDetailView : ConstraintLayout {
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to get Playback Tracker", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
};
}
};
@@ -982,7 +999,7 @@ class VideoDetailView : ConstraintLayout {
_title.text = video.name;
_channelName.text = video.author.name;
if(video.author.subscribers != null) {
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else {
_channelMeta.text = "";
@@ -1007,7 +1024,7 @@ class VideoDetailView : ConstraintLayout {
_platform.setPlatformFromClientID(video.id.pluginId);
val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0)
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}");
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) context.getString(R.string.watching_now) else context.getString(R.string.views)}");
if(video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0;
val ago = video.datetime?.toHumanNowDiffString(true)
@@ -1053,7 +1070,9 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
@@ -1166,7 +1185,7 @@ class VideoDetailView : ConstraintLayout {
livePager = StatePlatform.instance.getLiveEvents(video.url);
} catch (ex: Throwable) {
livePager = null;
UIDialogs.toast("Exception retrieving live events:\n" + ex.message);
UIDialogs.toast(context.getString(R.string.exception_retrieving_live_events) + "\n" + ex.message);
Logger.e(TAG, "Failed to retrieve live chat events", ex);
}
try {
@@ -1177,7 +1196,7 @@ class VideoDetailView : ConstraintLayout {
}
catch(ex: Throwable) {
liveChatWindow = null;
UIDialogs.toast("Exception retrieving live chat window:\n" + ex.message);
UIDialogs.toast(context.getString(R.string.exception_retrieving_live_chat_window) + "\n" + ex.message);
Logger.e(TAG, "Failed to retrieve live chat window", ex);
}
val liveChat = livePager?.let {
@@ -1199,7 +1218,7 @@ class VideoDetailView : ConstraintLayout {
catch(ex: Throwable) {
Logger.e(TAG, "Failed to load live chat", ex);
UIDialogs.toast("Live chat failed to load\n" + ex.message);
UIDialogs.toast(context.getString(R.string.live_chat_failed_to_load) + "\n" + ex.message);
//_liveChat?.handleEvents(listOf(LiveEventComment("SYSTEM", null, "Failed to load live chat:\n" + ex.message, "#FF0000")))
/*
fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -1254,9 +1273,13 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource;
_lastAudioSource = audioSource;
}
catch(ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to load media", ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to load media", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
}
}
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) {
@@ -1274,7 +1297,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)")
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource))
UIDialogs.toast(context, "Offline Playback", false);
UIDialogs.toast(context, context.getString(R.string.offline_playback), false);
//If LiveStream, set to end
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
if (video?.isLive == true) {
@@ -1315,12 +1338,12 @@ class VideoDetailView : ConstraintLayout {
_didTriggerDatasourceError = true;
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
"Media Error",
"The media source encountered an unauthorized error.\nThis might be solved by a plugin reload.\nWould you like to reload?\n(Experimental)",
context.getString(R.string.media_error),
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
null,
0,
UIDialogs.Action("No", { _didTriggerDatasourceError = false }),
UIDialogs.Action("Yes", {
UIDialogs.Action(context.getString(R.string.no), { _didTriggerDatasourceError = false }),
UIDialogs.Action(context.getString(R.string.yes), {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
StatePlatform.instance.reloadClient(context, config.id);
@@ -1418,8 +1441,9 @@ class VideoDetailView : ConstraintLayout {
?.filter { it.container == bestAudioContainer }
?.toList() ?: listOf();
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, "Quality", null, true,
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle("Playback Rate") } else null,
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true,
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null,
if (!_isCasting) SlideUpMenuButtonList(this.context).apply {
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), _player.getPlaybackRate().toString());
onClick.subscribe { v ->
@@ -1433,7 +1457,7 @@ class VideoDetailView : ConstraintLayout {
} else null,
if(localVideoSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Video", "video",
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
*localVideoSources.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
@@ -1441,7 +1465,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(localAudioSource?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Audio", "audio",
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
@@ -1449,7 +1473,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(localSubtitleSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Subtitles", "subtitles",
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
*localSubtitleSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
@@ -1457,7 +1481,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, "Stream Video", "video",
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
@@ -1465,7 +1489,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, "Stream Audio", "audio",
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
@@ -1474,7 +1498,7 @@ class VideoDetailView : ConstraintLayout {
else null,
if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, "Video", "video",
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
@@ -1482,7 +1506,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, "Audio", "audio",
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources.stream()
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
@@ -1490,7 +1514,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray())
else null,
if(video?.subtitles?.isNotEmpty() ?: false && video != null)
SlideUpMenuGroup(this.context, "Subtitles", "subtitles",
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
*video.subtitles
.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
@@ -1613,12 +1637,13 @@ class VideoDetailView : ConstraintLayout {
private fun handleUnavailableVideo(msg: String? = null) {
if (!nextVideo()) {
if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1))
UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", msg ?: "This video is unavailable.", null, 0,
UIDialogs.Action("Back", {
UIDialogs.showDialog(context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), msg ?: context.getString(R.string.this_video_is_unavailable), null, 0,
UIDialogs.Action(context.getString(R.string.back), {
this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY)
);
} else {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_UNAVAILABLE", "Unavailable video", "There was an unavailable video in your queue [${video?.name}] by [${video?.author?.name}].", AnnouncementType.SESSION)
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_UNAVAILABLE", context.getString(R.string.unavailable_video), context.getString(R.string.there_was_an_unavailable_video_in_your_queue_videoname_by_authorname).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
}
video?.let { StatePlatform.instance.clearContentDetailCache(it.url) };
@@ -1870,9 +1895,9 @@ class VideoDetailView : ConstraintLayout {
_player.getGlobalVisibleRect(r);
r.right = r.right - _player.paddingEnd;
val playpauseAction = if(_player.playing)
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), "Pause", "Pauses the video", MediaControlReceiver.getPauseIntent(context, 5));
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
else
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), "Play", "Resumes the video", MediaControlReceiver.getPlayIntent(context, 6));
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
return PictureInPictureParams.Builder()
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
@@ -1971,14 +1996,24 @@ class VideoDetailView : ConstraintLayout {
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile;
if (cachedPolycentricProfile?.profile == null) {
_layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate);
return;
val dp_35 = 35.dp(context.resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
_layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate);
if (profile != null) {
_channelName.text = cachedPolycentricProfile.profile.systemState.username;
_layoutMonetization.visibility = View.VISIBLE;
} else {
_layoutMonetization.visibility = View.GONE;
}
}
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -2051,7 +2086,7 @@ class VideoDetailView : ConstraintLayout {
}, UIDialogs.ActionStyle.PRIMARY)
);
} else {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", "Video without source", "There was a in your queue [${video?.name}] by [${video?.author?.name}] without the required source being enabled, playback was skipped.", AnnouncementType.SESSION)
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", context.getString(R.string.video_without_source), context.getString(R.string.there_was_a_in_your_queue_videoname_by_authorname_without_the_required_source_being_enabled_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
}
}
.exception<ContentNotAvailableYetException> {
@@ -2070,9 +2105,10 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "exception<ScriptImplementationException>", it)
if (!nextVideo()) {
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptImplementationException)", it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo);
} else {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", "Invalid video", "There was an invalid video in your queue [${video?.name}] by [${video?.author?.name}], playback was skipped.", AnnouncementType.SESSION)
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", context.getString(R.string.invalid_video), context.getString(
R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
}
}
.exception<ScriptAgeException> {
@@ -2087,7 +2123,9 @@ class VideoDetailView : ConstraintLayout {
this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY));
} else {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_AGERESTRICT", "Age restricted video", "There was an age restricted video in your queue [${video?.name}] by [${video?.author?.name}], this video was not accessible and playback was skipped.", AnnouncementType.SESSION)
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_AGERESTRICT", context.getString(R.string.age_restricted_video),
context.getString(R.string.there_was_an_age_restricted_video_in_your_queue_videoname_by_authorname_this_video_was_not_accessible_and_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""),
AnnouncementType.SESSION)
}
}
.exception<ScriptUnavailableException> {
@@ -2103,7 +2141,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, ::fetchVideo);
}
}
.exception<Throwable> {
@@ -2115,7 +2153,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo);
}
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
@@ -2156,7 +2194,7 @@ class VideoDetailView : ConstraintLayout {
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
fragment.lifecycleScope.launch(Dispatchers.Main){
UIDialogs.toast(context, "Not yet available, retrying in ${toWait}s");
UIDialogs.toast(context, context.getString(R.string.not_yet_available_retrying_in_time_s).replace("{time}", toWait.toString()));
}
_liveTryJob?.cancel();
@@ -2170,7 +2208,7 @@ class VideoDetailView : ConstraintLayout {
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
if(videoDetail.datetime!! > OffsetDateTime.now())
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}");
UIDialogs.toast(context, context.getString(R.string.planned_in) + " ${videoDetail.datetime?.toHumanNowDiffString(true)}");
}
startLiveTry(liveTryVideo);
}
@@ -2183,7 +2221,7 @@ class VideoDetailView : ConstraintLayout {
catch(ex: Throwable) {
Logger.e(TAG, "Failed to live try fetch video.", ex);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to retry for live stream");
UIDialogs.toast(context, context.getString(R.string.failed_to_retry_for_live_stream));
}
}
}
@@ -57,10 +57,8 @@ abstract class VideoListEditorView : LinearLayout {
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
buttonShuffle.setOnClickListener { onShuffleClick(); };
if (canEdit())
_buttonEdit.setOnClickListener { onEditClick(); };
else
_buttonEdit.visibility = View.GONE;
_buttonEdit.setOnClickListener { onEditClick(); };
setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
@@ -93,7 +91,7 @@ abstract class VideoListEditorView : LinearLayout {
}
protected fun setVideoCount(videoCount: Int = -1) {
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} videos";
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos);
}
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
@@ -107,7 +105,7 @@ abstract class VideoListEditorView : LinearLayout {
.into(it);
};
} else {
_textMetadata.text = "0 videos";
_textMetadata.text = "0 " + context.getString(R.string.videos);
if(_imagePlaylistThumbnail != null) {
Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail)
@@ -35,7 +35,7 @@ class GeneralTopBarFragment : TopFragment() {
val view = inflater.inflate(R.layout.fragment_overview_top_bar, container, false);
view.findViewById<ImageView>(R.id.app_icon).setOnClickListener {
UIDialogs.toast("This app is in development. Please submit bug reports and understand that many features are incomplete.", true);
UIDialogs.toast(getString(R.string.this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete), true);
};
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
@@ -194,7 +194,7 @@ class SearchTopBarFragment : TopFragment() {
if (editSearch != null) {
val text = editSearch.text.toString();
if (text.length < 3) {
UIDialogs.toast("Please use at least 3 characters");
UIDialogs.toast(getString(R.string.please_use_at_least_3_characters));
return;
}
@@ -64,12 +64,11 @@ class Logging {
val client = OkHttpClient()
val response: Response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string();
return if (body != null) Json.decodeFromString<String>(body) else null;
return if (response.isSuccessful) {
response.body?.string();
} else {
Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" };
return null;
null;
}
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -12,6 +13,13 @@ import java.time.OffsetDateTime
class Subscription {
var channel: SerializedChannel;
//Settings
var doNotifications: Boolean = false;
var doFetchLive: Boolean = false;
var doFetchStreams: Boolean = true;
var doFetchVideos: Boolean = true;
var doFetchPosts: Boolean = false;
//Last found content
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
@@ -32,6 +40,7 @@ class Subscription {
//Last video interval
var uploadInterval : Int = 0;
var uploadStreamInterval : Int = 0;
var uploadPostInterval : Int = 0;
@@ -39,9 +48,10 @@ class Subscription {
this.channel = channel;
}
fun shouldFetchStreams() = lastLiveStream.getNowDiffDays() < 7;
fun shouldFetchLiveStreams() = lastLiveStream.getNowDiffDays() < 14;
fun shouldFetchPosts() = lastPost.getNowDiffDays() < 2;
fun shouldFetchVideos() = true;
fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7;
fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14;
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2;
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
@@ -49,7 +59,57 @@ class Subscription {
this.channel = SerializedChannel.fromChannel(channel);
}
fun updateVideoStatus(allVideos: List<IPlatformContent>? = null, liveStreams: List<IPlatformContent>? = null) {
fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) {
val interval: Int;
val mostRecent: OffsetDateTime?;
if(!initialPage.isEmpty()) {
val newestVideoDays = initialPage[0].datetime?.getNowDiffDays()?.toInt() ?: 0;
val diffs = mutableListOf<Int>()
for(i in (initialPage.size - 1) downTo 1) {
val currentVideoDays = initialPage[i].datetime?.getNowDiffDays();
val nextVideoDays = initialPage[i - 1].datetime?.getNowDiffDays();
if(currentVideoDays != null && nextVideoDays != null) {
val diff = nextVideoDays - currentVideoDays;
diffs.add(diff.toInt());
}
}
val averageDiff = if(diffs.size > 0)
newestVideoDays.coerceAtLeast(diffs.average().toInt())
else
newestVideoDays;
interval = averageDiff.coerceAtLeast(1);
mostRecent = initialPage[0].datetime;
}
else {
interval = 5;
mostRecent = null;
}
when(type) {
ResultCapabilities.TYPE_VIDEOS -> {
uploadInterval = interval;
if(mostRecent != null)
lastVideo = mostRecent;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_MIXED -> {
uploadInterval = interval;
if(mostRecent != null)
lastVideo = mostRecent;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_STREAMS -> {
uploadStreamInterval = interval;
if(mostRecent != null)
lastLiveStream = mostRecent;
lastStreamUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_POSTS -> {
uploadPostInterval = interval;
if(mostRecent != null)
lastPost = mostRecent;
lastPostUpdate = OffsetDateTime.now();
}
}
}
}
@@ -11,5 +11,6 @@ data class Telemetry(
val isUnstableBuild: Boolean,
val brand: String,
val manufacturer: String,
val model: String
val model: String,
val sdkVersion: Int
) { }
@@ -8,6 +8,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage
@@ -37,7 +38,8 @@ class PolycentricCache {
ContentType.AVATAR.value,
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value
ContentType.STORE.value,
ContentType.SERVER.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
@@ -88,8 +90,9 @@ class PolycentricCache {
if (result.profile != null) {
for (claim in result.profile.ownedClaims) {
val url = claim.claim.resolveChannelUrl() ?: continue;
_profileUrlCache.map[url] = result;
val urls = claim.claim.resolveChannelUrls();
for (url in urls)
_profileUrlCache.map[url] = result;
}
}
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.models.FilterGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -615,6 +616,14 @@ class StatePlatform {
}
}
fun getContentChapters(url: String): List<IChapter>? {
val baseClient = getContentClientOrNull(url) ?: return null;
if (baseClient !is JSClient) {
return baseClient.getContentChapters(url);
}
val client = _trackerClientPool.getClientPooled(baseClient, 1);
return client.getContentChapters(url);
}
fun getPlaybackTracker(url: String): IPlaybackTracker? {
val baseClient = getContentClientOrNull(url) ?: return null;
if (baseClient !is JSClient) {
@@ -642,11 +651,8 @@ class StatePlatform {
return _scope.async { getChannelLive(url, updateSubscriptions) };
}
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl, ignorePlugins);
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
_channelClientPool.getClientPooled(baseClient, usePooledClients);
else baseClient;
@@ -761,6 +767,20 @@ class StatePlatform {
return pagerResult;
}
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl, ignorePlugins);
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, ignorePlugins);
}
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
val client = getChannelClient(channelUrl);
return getChannelContent(client, channelUrl, type, ordering);
}
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
val client = _channelClientPool.getClientPooled(baseClient, Settings.instance.subscriptions.getSubscriptionsConcurrency());
return client.getChannelContents(channelUrl, type, ordering) ;
}
fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel {
val channel = getChannelClient(url).getChannel(url);
@@ -784,6 +804,15 @@ class StatePlatform {
return null;
}
fun resolveChannelUrlsByClaimTemplates(claimType: Int, claimValues: Map<Int, String>): List<String> {
val urls = arrayListOf<String>();
for(client in getClientsByClaimType(claimType).filter { it is JSClient }) {
val res = (client as JSClient).resolveChannelUrlsByClaimTemplates(claimType, claimValues);
urls.addAll(res);
}
return urls;
}
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
@@ -116,8 +116,9 @@ class StatePlugins {
else if(embeddedConfig != null) {
val existing = getPlugin(embedded.key);
if(existing != null && existing.config.version < embeddedConfig.version ) {
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling");
deletePlugin(embedded.key);
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
//deletePlugin(embedded.key);
installEmbeddedPlugin(context, embedded.value)
}
else if(existing != null && _isFirstEmbedUpdate)
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
@@ -360,6 +361,8 @@ class StatePlugins {
}
val existing = getPlugin(config.id)
val existingAuth = existing?.getAuth();
val existingCaptcha = existing?.getCaptchaData();
if (existing != null) {
if(!reinstall)
throw IllegalStateException("Plugin with id ${config.id} already exists");
@@ -373,7 +376,7 @@ class StatePlugins {
if(icon != null)
iconsDir.saveIconBinary(config.id, icon);
_plugins.save(SourcePluginDescriptor(config, null, null, flags));
_plugins.save(SourcePluginDescriptor(config, existingAuth?.toEncrypted(), existingCaptcha?.toEncrypted(), flags));
return null;
}
catch(ex: Throwable) {
@@ -19,6 +19,7 @@ import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlaceholderPager
import com.futo.platformplayer.api.media.structures.RefreshChronoContentPager
import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager
import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
import com.futo.platformplayer.awaitFirstDeferred
@@ -130,10 +131,7 @@ class StatePolycentric {
//TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
if (!StatePlatform.instance.hasEnabledChannelClient(url)) {
return@mapNotNull null;
}
@@ -146,15 +144,39 @@ class StatePolycentric {
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
}
fun getChannelUrls(url: String, channelId: PlatformID? = null): List<String> {
var polycentricProfile: PolycentricProfile? = null;
try {
polycentricProfile = PolycentricCache.instance.getCachedProfile(url)?.profile;
if (polycentricProfile == null && channelId != null) {
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId) }?.profile;
} else {
Logger.i("StateSubscriptions", "Get polycentric profile cached");
}
}
catch(ex: Throwable) {
Logger.w(StateSubscriptions.TAG, "Polycentric getCachedProfile failed for subscriptions", ex);
//TODO: Some way to communicate polycentric failing without blocking here
}
if(polycentricProfile != null) {
val urls = polycentricProfile.ownedClaims.groupBy { it.claim.claimType }
.mapNotNull { it.value.firstOrNull()?.claim?.resolveChannelUrl() }.toMutableList();
if(urls.any { it.equals(url, true) })
return urls;
else
return listOf(url) + urls;
}
else
return listOf(url);
}
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
//TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
.mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
val client = StatePlatform.instance.getChannelClientOrNull(url) ?: return@mapNotNull null;
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
@@ -173,12 +195,21 @@ class StatePolycentric {
}) ?: return null;
val toAwait = deferred.filter { it.second != finishedPager.first };
//TODO: Get a Parallel pager to work here.
val innerPager = MultiChronoContentPager(listOf(finishedPager.second!!) + toAwait.mapNotNull { runBlocking { it.second.await(); } });
innerPager.initialize();
//return RefreshChronoContentPager(listOf(finishedPager.second!!), toAwait.map { it.second }, listOf());
//return RefreshDedupContentPager(RefreshChronoContentPager(listOf(finishedPager.second!!), toAwait.map { it.second }, listOf()), StatePlatform.instance.getEnabledClients().map { it.id });
return DedupContentPager(innerPager, StatePlatform.instance.getEnabledClients().map { it.id });
/* //Gives out-of-order results
return RefreshDedupContentPager(RefreshDistributionContentPager(
listOf(finishedPager.second!!),
toAwait.map { it.second },
toAwait.map { PlaceholderPager(5) { PlatformContentPlaceholder(it.first.id) } }),
StatePlatform.instance.getEnabledClients().map { it.id }
);
);*/
}
suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> {
return withContext(Dispatchers.IO) {
@@ -29,6 +29,8 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import kotlinx.coroutines.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
@@ -53,7 +55,6 @@ class StateSubscriptions {
private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency());
private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
private var _globalSubscriptionsLock = Object();
private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null;
@@ -62,6 +63,8 @@ class StateSubscriptions {
var globalSubscriptionExceptions: List<Throwable> = listOf()
private set;
private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SMART;
private var _lastGlobalSubscriptionProgress: Int = 0;
private var _lastGlobalSubscriptionTotal: Int = 0;
val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>();
@@ -69,6 +72,8 @@ class StateSubscriptions {
val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>();
val onGlobalSubscriptionsException = Event1<List<Throwable>>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
}
@@ -223,167 +228,26 @@ class StateSubscriptions {
}
fun getSubscriptionRequestCount(): Map<JSClient, Int> {
val subs = getSubscriptions();
val pluginReqCounts = mutableMapOf<JSClient, Int>();
for(sub in subs) {
val client = StatePlatform.instance.getChannelClientOrNull(sub.channel.url);
if(client !is JSClient)
continue;
val channelCaps = client.getChannelCapabilities();
if(!pluginReqCounts.containsKey(client))
pluginReqCounts[client] = 1;
else
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.shouldFetchStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.shouldFetchLiveStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.shouldFetchPosts())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
}
return pluginReqCounts;
return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
.countRequests(getSubscriptions());
}
fun getSubscriptionsFeed(allowFailure: Boolean = false): IPager<IPlatformContent> {
val result = getSubscriptionsFeedWithExceptions(allowFailure, true);
if(result.second.any())
throw result.second.first();
return result.first;
}
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val subsPager: Array<IPager<IPlatformContent>>;
val exs: ArrayList<Throwable> = arrayListOf();
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
val tasks = mutableListOf<ForkJoinTask<Pair<Subscription, IPager<IPlatformContent>?>>>();
var finished = 0;
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
val failedPlugins = arrayListOf<String>();
for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) {
tasks.add(_subscriptionsPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null;
val getProfileTime = measureTimeMillis {
try {
polycentricProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url);
if (polycentricProfile == null) {
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(sub.channel.id) };
} else {
Logger.i("StateSubscriptions", "Get polycentric profile cached");
}
}
catch(ex: Throwable) {
Logger.w(TAG, "Polycentric getCachedProfile failed for subscriptions", ex);
//TODO: Some way to communicate polycentric failing without blocking here
//UIDialogs.toast("Polycentric failed\n" + ex.message, false);
//UIDialogs.showGeneralErrorDialog(it, "Polycentric getCachedProfile failed for subscriptions", ex);
}
}
Logger.i("StateSubscriptions", "Get polycentric profile time ${getProfileTime}ms");
var pager: IPager<IPlatformContent>;
try {
val time = measureTimeMillis {
val profile = polycentricProfile?.profile
pager = if (profile != null)
StatePolycentric.instance.getChannelContent(profile, true, concurrency, toIgnore)
else
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency, toIgnore);
if (cacheScope != null)
pager = ChannelContentCache.cachePagerResults(cacheScope, pager) {
onNewCacheHit?.invoke(sub, it);
};
finished++;
onProgress?.invoke(finished, tasks.size);
}
Logger.i(
"StateSubscriptions",
"Subscription [${sub.channel.name}] results in ${time}ms"
);
}
catch(ex: Throwable) {
Logger.e(TAG, "Subscription [${sub.channel.name}] failed", ex);
finished++;
onProgress?.invoke(finished, tasks.size);
val channelEx = ChannelException(sub.channel, ex);
synchronized(exceptionMap) {
exceptionMap.put(sub, channelEx);
}
if(ex is ScriptCaptchaRequiredException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a captcha issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(TAG, "Subscriptionsgnoring plugin [${ex.config.name}] due to Captcha");
failedPlugins.add(ex.config.id);
}
}
}
else if(ex is ScriptCriticalException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a critical issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
failedPlugins.add(ex.config.id);
}
}
}
if(!withCacheFallback)
throw channelEx;
else {
Logger.i(TAG, "Channel ${sub.channel.name} failed, substituting with cache");
pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url);
}
}
return@submit Pair(sub, pager);
});
algo.onProgress.subscribe { progress, total ->
onProgress?.invoke(progress, total);
}
val timeTotal = measureTimeMillis {
val taskResults = arrayListOf<IPager<IPlatformContent>>();
for(task in tasks) {
try {
val result = task.get();
if(result != null) {
if(result.second != null)
taskResults.add(result.second!!);
if(exceptionMap.containsKey(result.first)) {
val ex = exceptionMap[result.first];
if(ex != null) {
val nonRuntimeEx = findNonRuntimeException(ex);
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
}
}
}
} catch (ex: ExecutionException) {
val nonRuntimeEx = findNonRuntimeException(ex.cause);
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
};
}
subsPager = taskResults.toTypedArray();
algo.onNewCacheHit.subscribe { sub, content ->
}
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
if(subsPager.size <= 0 && exs.any())
throw exs.first();
val subUrls = getSubscriptions().associateWith {
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id)
};
Logger.i(TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize();
//return Pair(pager, exs);
return Pair(DedupContentPager(pager), exs);
val result = algo.getSubscriptions(subUrls);
return Pair(result.pager, result.exceptions);
}
//New Migration
@@ -39,7 +39,8 @@ class StateTelemetry {
BuildConfig.IS_UNSTABLE_BUILD,
Build.BRAND,
Build.MANUFACTURER,
Build.MODEL
Build.MODEL,
Build.VERSION.SDK_INT
);
val headers = hashMapOf(
@@ -0,0 +1,39 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toSafeFileName
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ForkJoinPool
class CachedSubscriptionAlgorithm(pageSize: Int = 150, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, threadPool: ForkJoinPool? = null)
: SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
private val _pageSize: Int = pageSize;
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
return mapOf<JSClient, Int>();
}
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet();
val validStores = ChannelContentCache.instance._channelContents
.filter { validSubIds.contains(it.key) }
.map { it.value };
val items = validStores.flatMap { it.getItems() }
.sortedByDescending { it.datetime };
return Result(DedupContentPager(PlatformContentPager(items, Math.min(_pageSize, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }), listOf());
}
}
@@ -0,0 +1,188 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import java.lang.Exception
import java.lang.IllegalStateException
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.system.measureTimeMillis
class SimpleSubscriptionAlgorithm(
scope: CoroutineScope,
allowFailure: Boolean = false,
withCacheFallback: Boolean = true,
threadPool: ForkJoinPool? = null
): SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
val pluginReqCounts = mutableMapOf<JSClient, Int>();
for(sub in subs) {
for(subUrl in sub.value) {
val client = StatePlatform.instance.getChannelClientOrNull(sub.key.channel.url);
if (client !is JSClient)
continue;
val channelCaps = client.getChannelCapabilities();
if (!pluginReqCounts.containsKey(client))
pluginReqCounts[client] = 1;
else
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if (channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.key.shouldFetchStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if (channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.key.shouldFetchLiveStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if (channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.key.shouldFetchPosts())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
}
}
return pluginReqCounts;
}
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val subsPager: Array<IPager<IPlatformContent>>;
val exs: ArrayList<Throwable> = arrayListOf();
Logger.i(TAG, "getSubscriptions [Simple]");
val tasks = mutableListOf<ForkJoinTask<Pair<Subscription, IPager<IPlatformContent>?>>>();
var finished = 0;
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
val failedPlugins = arrayListOf<String>();
for (sub in subs.filter { StatePlatform.instance.hasEnabledChannelClient(it.key.channel.url) })
tasks.add(getSubscription(sub.key, sub.value, failedPlugins){ channelEx ->
finished++;
onProgress.emit(finished, tasks.size);
val ex = channelEx?.cause;
if(channelEx != null) {
synchronized(exceptionMap) {
exceptionMap.put(sub.key, channelEx);
}
if(ex is ScriptCaptchaRequiredException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a captcha issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(StateSubscriptions.TAG, "Subscriptionsgnoring plugin [${ex.config.name}] due to Captcha");
failedPlugins.add(ex.config.id);
}
}
}
else if(ex is ScriptCriticalException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a critical issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
failedPlugins.add(ex.config.id);
}
}
}
}
});
val timeTotal = measureTimeMillis {
val taskResults = arrayListOf<IPager<IPlatformContent>>();
for(task in tasks) {
try {
val result = task.get();
if(result != null) {
if(result.second != null)
taskResults.add(result.second!!);
if(exceptionMap.containsKey(result.first)) {
val ex = exceptionMap[result.first];
if(ex != null) {
val nonRuntimeEx = findNonRuntimeException(ex);
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
}
}
}
} catch (ex: ExecutionException) {
val nonRuntimeEx = findNonRuntimeException(ex.cause);
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
};
}
subsPager = taskResults.toTypedArray();
}
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
if(subsPager.size <= 0 && exs.any())
throw exs.first();
Logger.i(StateSubscriptions.TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize();
//return Pair(pager, exs);
return Result(DedupContentPager(pager), exs);
}
private fun getSubscription(sub: Subscription, urls: List<String>, failedPlugins: List<String>, onFinished: (ChannelException?)->Unit): ForkJoinTask<Pair<Subscription, IPager<IPlatformContent>?>> {
return threadPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
var pager: IPager<IPlatformContent>? = null;
for(url in urls) {
try {
val platformClient = StatePlatform.instance.getChannelClientOrNull(url, toIgnore) ?: continue;
val time = measureTimeMillis {
pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore);
pager = ChannelContentCache.cachePagerResults(scope, pager!!) {
onNewCacheHit.emit(sub, it);
};
onFinished(null);
}
Logger.i(
"StateSubscriptions",
"Subscription [${sub.channel.name}] results in ${time}ms"
);
}
catch(ex: Throwable) {
Logger.e(StateSubscriptions.TAG, "Subscription [${sub.channel.name}] failed", ex);
val channelEx = ChannelException(sub.channel, ex);
onFinished(channelEx);
if(!withCacheFallback)
throw channelEx;
else {
Logger.i(StateSubscriptions.TAG, "Channel ${sub.channel.name} failed, substituting with cache");
pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url);
}
}
}
if(pager == null)
throw IllegalStateException("Uncaught nullable pager");
return@submit Pair(sub, pager);
};
}
}
@@ -0,0 +1,107 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StatePlatform
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ForkJoinPool
class SmartSubscriptionAlgorithm(
scope: CoroutineScope,
allowFailure: Boolean = false,
withCacheFallback: Boolean = true,
threadPool: ForkJoinPool? = null
): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) {
override fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask> {
val allTasks: List<SubscriptionTask> = subs.flatMap { entry ->
val sub = entry.key;
//Get all urls associated with this subscriptions
val allPlatforms = entry.value.associateWith { StatePlatform.instance.getChannelClientOrNull(it) }
.filterValues { it is JSClient };
//For every platform, get all sub-queries associated with that platform
return@flatMap allPlatforms
.filter { it.value != null }
.flatMap {
val url = it.key;
val client = it.value!! as JSClient;
val capabilities = client.getChannelCapabilities();
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED))
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_MIXED));
else {
val types = listOf(
if(sub.shouldFetchVideos()) ResultCapabilities.TYPE_VIDEOS else null,
if(sub.shouldFetchStreams()) ResultCapabilities.TYPE_STREAMS else null,
if(sub.shouldFetchPosts()) ResultCapabilities.TYPE_POSTS else null,
if(sub.shouldFetchLiveStreams()) ResultCapabilities.TYPE_LIVE else null
).filterNotNull().filter { capabilities.hasType(it) };
return@flatMap types.map {
SubscriptionTask(client, sub, url, it);
};
}
};
};
val ordering = allTasks.groupBy { it.client }
.map { Pair(it.key, it.value.sortedBy { calculateUpdateUrgency(it.sub, it.type) }) };
val finalTasks = mutableListOf<SubscriptionTask>();
for(clientTasks in ordering) {
val limit = clientTasks.first.config.subscriptionRateLimit;
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;
Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}")
finalTasks.addAll(fetchTasks + cacheTasks);
}
}
return finalTasks;
}
fun calculateUpdateUrgency(sub: Subscription, type: String): Int {
val lastItem = when(type) {
ResultCapabilities.TYPE_VIDEOS -> sub.lastVideo;
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStream;
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStream;
ResultCapabilities.TYPE_POSTS -> sub.lastPost;
else -> sub.lastVideo; //TODO: minimum of all
};
val lastUpdate = when(type) {
ResultCapabilities.TYPE_VIDEOS -> sub.lastVideoUpdate;
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStreamUpdate;
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStreamUpdate;
ResultCapabilities.TYPE_POSTS -> sub.lastPostUpdate;
else -> sub.lastVideoUpdate; //TODO: minimum of all
};
val interval = when(type) {
ResultCapabilities.TYPE_VIDEOS -> sub.uploadInterval;
ResultCapabilities.TYPE_STREAMS -> sub.uploadStreamInterval;
ResultCapabilities.TYPE_LIVE -> sub.uploadStreamInterval;
ResultCapabilities.TYPE_POSTS -> sub.uploadPostInterval;
else -> sub.uploadInterval; //TODO: minimum of all
};
val lastItemDaysAgo = lastItem.getNowDiffHours();
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
val expectedHours = lastUpdateHoursAgo.toDouble() - (interval*24);
return (expectedHours * 100).toInt();
}
}
@@ -0,0 +1,48 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.models.Subscription
import kotlinx.coroutines.CoroutineScope
import java.lang.Exception
import java.lang.IllegalStateException
import java.util.concurrent.ForkJoinPool
abstract class SubscriptionFetchAlgorithm(
val scope: CoroutineScope,
val allowFailure: Boolean = false,
val withCacheFallback: Boolean = true,
private val _threadPool: ForkJoinPool? = null
) {
val threadPool: ForkJoinPool get() = _threadPool ?: throw IllegalStateException("Require thread pool parameter");
val onNewCacheHit = Event2<Subscription, IPlatformContent>();
val onProgress = Event2<Int, Int>();
fun countRequests(subs: List<Subscription>): Map<JSClient, Int> = countRequests(subs.associateWith { listOf(it.channel.url) });
abstract fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int>;
fun getSubscriptions(subs: List<Subscription>): Result = getSubscriptions(subs.associateWith { listOf(it.channel.url) });
abstract fun getSubscriptions(subs: Map<Subscription, List<String>>): Result;
class Result(
val pager: IPager<IPlatformContent>,
val exceptions: List<Throwable>
);
companion object {
public val TAG = "SubscriptionAlgorithm";
fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm {
return when(algo) {
SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(150, scope, allowFailure, withCacheFallback, pool);
SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
else -> throw IllegalStateException("Unknown algorithm ${algo}");
}
}
}
}
@@ -0,0 +1,7 @@
package com.futo.platformplayer.subscription
enum class SubscriptionFetchAlgorithms(val value: Int) {
CACHE(1),
SIMPLE(2),
SMART(3);
}
@@ -0,0 +1,171 @@
package com.futo.platformplayer.subscription
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.coroutines.CoroutineScope
import java.lang.IllegalStateException
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.system.measureTimeMillis
abstract class SubscriptionsTaskFetchAlgorithm(
scope: CoroutineScope,
allowFailure: Boolean = false,
withCacheFallback: Boolean = true,
_threadPool: ForkJoinPool? = null
) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) {
override fun countRequests(subs: Map<Subscription, List<String>>): Map<JSClient, Int> {
return getSubscriptionTasks(subs).groupBy { it.client }.toList()
.associate { Pair(it.first, it.second.filter { !it.fromCache }.size) };
}
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val tasks = getSubscriptionTasks(subs);
Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
" Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
" Cached: ${tasks.filter { it.fromCache }.size}");
try {
//TODO: Remove this
UIDialogs.toast("Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
"Cached: ${tasks.filter { it.fromCache }.size}", false);
} catch (ex: Throwable){}
val exs: ArrayList<Throwable> = arrayListOf();
val taskResults = arrayListOf<IPager<IPlatformContent>>();
val forkTasks = mutableListOf<ForkJoinTask<SubscriptionTaskResult>>();
var finished = 0;
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
val failedPlugins = arrayListOf<String>();
val cachedChannels = arrayListOf<String>();
for(task in tasks) {
val forkTask = threadPool.submit<SubscriptionTaskResult> {
synchronized(cachedChannels) {
if(task.fromCache) {
finished++;
onProgress.emit(finished, forkTasks.size);
if(cachedChannels.contains(task.url))
return@submit SubscriptionTaskResult(task, null, null);
else {
cachedChannels.add(task.url);
return@submit SubscriptionTaskResult(task, ChannelContentCache.instance.getChannelCachePager(task.url), null);
}
}
}
val shouldIgnore = synchronized(failedPlugins) { failedPlugins.contains(task.client.id) };
if(shouldIgnore)
return@submit SubscriptionTaskResult(task, null, null); //skipped
var taskEx: Throwable? = null;
var pager: IPager<IPlatformContent>;
try {
val time = measureTimeMillis {
pager = StatePlatform.instance.getChannelContent(task.client,
task.url, task.type, ResultCapabilities.ORDER_CHONOLOGICAL);
pager = ChannelContentCache.cachePagerResults(scope, pager) {
onNewCacheHit.emit(task.sub, it);
};
val initialPage = pager.getResults();
task.sub.updateSubscriptionState(task.type, initialPage);
StateSubscriptions.instance.saveSubscription(task.sub);
finished++;
onProgress.emit(finished, forkTasks.size);
}
Logger.i("StateSubscriptions", "Subscription [${task.sub.channel.name}]:${task.type} results in ${time}ms");
return@submit SubscriptionTaskResult(task, pager, null);
} catch (ex: Throwable) {
Logger.e(StateSubscriptions.TAG, "Subscription [${task.sub.channel.name}] failed", ex);
val channelEx = ChannelException(task.sub.channel, ex);
finished++;
onProgress.emit(finished, forkTasks.size);
if (!withCacheFallback)
throw channelEx;
else {
Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache");
pager = ChannelContentCache.instance.getChannelCachePager(task.sub.channel.url);
taskEx = ex;
}
}
return@submit SubscriptionTaskResult(task, null, taskEx);
}
forkTasks.add(forkTask);
}
val timeTotal = measureTimeMillis {
for(task in forkTasks) {
try {
val result = task.get();
if(result != null) {
if(result.pager != null)
taskResults.add(result.pager!!);
if(exceptionMap.containsKey(result.task.sub)) {
val ex = exceptionMap[result.task.sub];
if(ex != null) {
val nonRuntimeEx = findNonRuntimeException(ex);
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
}
}
}
} catch (ex: ExecutionException) {
val nonRuntimeEx = findNonRuntimeException(ex.cause);
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
exs.add(nonRuntimeEx);
else
throw ex.cause ?: ex;
};
}
}
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
val pager = MultiChronoContentPager(taskResults, allowFailure, 15);
pager.initialize();
return Result(DedupContentPager(pager), exs);
}
abstract fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask>;
class SubscriptionTask(
val client: JSClient,
val sub: Subscription,
val url: String,
val type: String,
var fromCache: Boolean = false
);
class SubscriptionTaskResult(
val task: SubscriptionTask,
val pager: IPager<IPlatformContent>?,
val exception: Throwable?
)
}
@@ -70,11 +70,13 @@ class CommentViewHolder : ViewHolder {
args.processHandle.opinion(c.reference, Opinion.neutral);
}
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes / (args.likes + args.dislikes) >= 0.7) 0.5f else 1.0f;
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e)
}
@@ -93,6 +95,7 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
_textAuthor.text = comment.author.name;
val date = comment.date;
@@ -105,7 +108,7 @@ class CommentViewHolder : ViewHolder {
val rating = comment.rating;
if (rating is RatingLikeDislikes) {
_layoutComment.alpha = if (rating.dislikes > 0 && rating.dislikes / (rating.likes + rating.dislikes) >= 0.7) 0.5f else 1.0f;
_layoutComment.alpha = if (rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
} else {
_layoutComment.alpha = 1.0f;
}
@@ -159,7 +162,7 @@ class CommentViewHolder : ViewHolder {
val replies = comment.replyCount ?: 0;
if (!readonly || replies > 0) {
_buttonReplies.visibility = View.VISIBLE;
_buttonReplies.text.text = "$replies replies";
_buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies);
} else {
_buttonReplies.visibility = View.GONE;
}
@@ -38,7 +38,7 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open";
_textSourceSubtitle.text = context.getString(R.string.tap_to_open);
_buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); };
@@ -38,7 +38,7 @@ class DisabledSourceViewHolder : ViewHolder {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open";
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client;
}
}
@@ -57,7 +57,7 @@ class EnabledSourceViewHolder : ViewHolder {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open";
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client
}
}
@@ -89,7 +89,7 @@ class HistoryListViewHolder : ViewHolder {
var metadata = "";
if (v.video.viewCount > 0)
metadata += "${v.video.viewCount.toHumanNumber()} views";
metadata += "${v.video.viewCount.toHumanNumber()} " + itemView.context.getString(R.string.views);
_textMetadata.text = metadata;
@@ -32,6 +32,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
open class PreviewVideoView : LinearLayout {
@@ -58,11 +59,12 @@ open class PreviewVideoView : LinearLayout {
protected val _exoPlayer: PlayerManager?;
private val _taskLoadValidClaims = TaskHandler<PlatformID, PolycentricCache.CachedOwnedClaims>(StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getValidClaimsAsync(it).await() })
.success { it -> updateClaimsLayout(it, animate = true) }
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
Logger.w(TAG, "Failed to load profile.", it);
};
val onVideoClicked = Event2<IPlatformVideo, Long>();
@@ -145,15 +147,7 @@ open class PreviewVideoView : LinearLayout {
open fun bind(content: IPlatformContent) {
_taskLoadValidClaims.cancel();
val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id);
if (cachedClaims != null) {
updateClaimsLayout(cachedClaims, animate = false);
} else {
updateClaimsLayout(null, animate = false);
_taskLoadValidClaims.run(content.author.id);
}
_taskLoadProfile.cancel();
isClickable = true;
@@ -161,16 +155,25 @@ open class PreviewVideoView : LinearLayout {
stopPreview();
if(_imageChannel != null)
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
} else {
_imageNeopassChannel?.visibility = View.GONE;
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
_imageChannel?.let {
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
}
_taskLoadProfile.run(content.author.id);
_textChannelName.text = content.author.name
}
_imageChannel?.clipToOutline = true;
_textVideoName.text = content.name;
_textChannelName.text = content.author.name
_layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(content.id)) VISIBLE else GONE;
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
@@ -178,15 +181,15 @@ open class PreviewVideoView : LinearLayout {
var metadata = ""
if (content is IPlatformVideo && content.viewCount > 0) {
if(content.isLive)
metadata += "${content.viewCount.toHumanNumber()} watching • ";
metadata += "${content.viewCount.toHumanNumber()} ${context.getString(R.string.watching)}";
else
metadata += "${content.viewCount.toHumanNumber()} views • ";
metadata += "${content.viewCount.toHumanNumber()} ${context.getString(R.string.views)}";
}
var timeMeta = "";
if(isPlanned) {
val ago = content.datetime?.toHumanNowDiffString(true) ?: ""
timeMeta = "available in " + ago;
timeMeta = context.getString(R.string.available_in) + " ${ago}";
}
else {
val ago = content.datetime?.toHumanNowDiffString() ?: ""
@@ -207,13 +210,13 @@ open class PreviewVideoView : LinearLayout {
if(!isPlanned)
_textVideoDuration.text = video.duration.toHumanTime(false);
else
_textVideoDuration.text = "Planned";
_textVideoDuration.text = context.getString(R.string.planned);
_playerVideoThumbnail?.setLive(video.isLive);
if(!isPlanned && video.isLive) {
_containerDuration.visibility = GONE;
_containerLive.visibility = VISIBLE;
timeMeta = "LIVE"
timeMeta = context.getString(R.string.live_capitalized)
}
else {
_containerLive.visibility = GONE;
@@ -296,22 +299,50 @@ open class PreviewVideoView : LinearLayout {
_playerVideoThumbnail?.setMuteChangedListener(callback);
}
private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) {
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_neopassAnimator?.cancel();
_neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty();
if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500)
_neopassAnimator?.start()
val profile = cachedPolycentricProfile?.profile;
if (_creatorThumbnail != null) {
val dp_32 = 32.dp(context.resources);
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_32 * dp_32)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
} else if (_imageChannel != null) {
val dp_28 = 28.dp(context.resources);
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_28 * dp_28)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_imageChannel.let {
Glide.with(_imageChannel)
.load(avatar)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
}
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500)
_neopassAnimator?.start()
} else {
_imageNeopassChannel?.alpha = 1.0f;
}
} else {
_imageNeopassChannel?.visibility = View.GONE
}
} else {
_imageNeopassChannel?.visibility = View.GONE
}
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate)
if (profile != null) {
_textChannelName.text = profile.systemState.username
}
}
companion object {
@@ -72,22 +72,27 @@ class SubscriptionViewHolder : ViewHolder {
} else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id);
_textName.text = sub.channel.name;
}
_textName.text = sub.channel.name;
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_46 = 46.dp(itemView.context.resources);
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) };
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_textName.text = profile.systemState.username;
}
}
@@ -103,7 +103,7 @@ class VideoListEditorViewHolder : ViewHolder {
var metadata = "";
if (v.viewCount > 0)
metadata += "${v.viewCount.toHumanNumber()} views • ";
metadata += "${v.viewCount.toHumanNumber()} ${itemView.context.getString(R.string.views)}";
metadata += v.datetime?.toHumanNowDiffString() ?: "";
_platformIndicator.setPlatformFromClientID(v.id.pluginId);
@@ -6,13 +6,23 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.SubscriptionViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Boolean) : AnyAdapter.AnyViewHolder<PlatformAuthorLink>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_creator, _viewGroup, false)) {
@@ -25,7 +35,15 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
private var _authorLink: PlatformAuthorLink? = null;
val onClick = Event1<PlatformAuthorLink>();
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load profile.", it);
};
init {
_textName = _view.findViewById(R.id.text_channel_name);
_creatorThumbnail = _view.findViewById(R.id.creator_thumbnail);
@@ -45,12 +63,21 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
}
override fun bind(authorLink: PlatformAuthorLink) {
_textName.text = authorLink.name;
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.cancel();
val cachedProfile = PolycentricCache.instance.getCachedProfile(authorLink.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
} else {
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.run(authorLink.id);
_textName.text = authorLink.name;
}
if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L)
_textMetadata.visibility = View.GONE;
else {
_textMetadata.text = if(authorLink?.subscribers ?: 0 > 0) authorLink.subscribers!!.toHumanNumber() + " subscribers" else "";
_textMetadata.text = if((authorLink.subscribers ?: 0) > 0) authorLink.subscribers!!.toHumanNumber() + " " + _view.context.getString(R.string.subscribers) else "";
_textMetadata.visibility = View.VISIBLE;
}
_buttonSubscribe.setSubscribeChannel(authorLink.url);
@@ -58,6 +85,25 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
_authorLink = authorLink;
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_61 = 61.dp(itemView.context.resources);
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_61 * dp_61)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_textName.text = profile.systemState.username;
}
}
companion object {
private const val TAG = "CreatorViewHolder";
}
@@ -45,7 +45,7 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
override fun bind(playlist: SelectablePlaylist) {
_textName.text = playlist.playlist.name;
_textMetadata.text = "${playlist.playlist.videos.size} videos";
_textMetadata.text = "${playlist.playlist.videos.size} " + _view.context.getString(R.string.videos);
_checkbox.value = playlist.selected;
val thumbnail = playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail();
@@ -57,22 +57,27 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
} else {
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_taskLoadProfile.run(subscription.channel.id);
_name.text = subscription.channel.name;
}
_name.text = subscription.channel.name;
_subscription = subscription;
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_55 = 55.dp(itemView.context.resources)
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55)
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) };
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_name.text = profile.systemState.username;
}
}
@@ -37,7 +37,7 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<
};
_videoDelete.setOnClickListener {
val id = _video?.id ?: return@setOnClickListener;
UIDialogs.showConfirmationDialog(_view.context, "Are you sure you want to delete this video?", {
UIDialogs.showConfirmationDialog(_view.context, _view.context.getString(R.string.are_you_sure_you_want_to_delete_this_video), {
StateDownloads.instance.deleteCachedVideo(id);
});
}
@@ -129,10 +129,10 @@ class AnnouncementView : LinearLayout {
_textClose.text = announcement.cancelName;
}
else
_textClose.text = "Dismiss";
_textClose.text = context.getString(R.string.dismiss);
}
else
_textClose.text = "Dismiss";
_textClose.text = context.getString(R.string.dismiss);
when (announcement.announceType) {
AnnouncementType.DELETABLE -> {
@@ -408,10 +408,10 @@ class GestureControlView : LinearLayout {
val seekOffset: Long = 10000;
if (_rewinding) {
_textRewind.text = "${_fastForwardCounter * 10} seconds";
_textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
onSeek.emit(-seekOffset);
} else {
_textFastForward.text = "${_fastForwardCounter * 10} seconds";
_textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
onSeek.emit(seekOffset);
}
}
@@ -13,7 +13,7 @@ import com.futo.platformplayer.constructs.Event0
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel
class BigButton : LinearLayout {
open class BigButton : LinearLayout {
private val _root: LinearLayout;
private val _icon: ShapeableImageView;
private val _textPrimary: TextView;
@@ -78,6 +78,10 @@ class BigButton : LinearLayout {
_textSecondary.text = text;
return this;
}
fun withSecondaryTextMaxLines(lines: Int): BigButton {
_textSecondary.maxLines = lines;
return this;
}
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (resourceId != -1) {
@@ -31,12 +31,12 @@ class AddCommentView : LinearLayout {
val now = System.currentTimeMillis()
if (now - _lastClickTime > 3000) {
StatePolycentric.instance.requireLogin(context, "Please login to post a comment") {
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_post_a_comment)) {
try {
UIDialogs.showCommentDialog(context, cu, ref) { onCommentAdded.emit(it) };
} catch (e: Throwable) {
Logger.w(TAG, "Failed to post comment", e);
UIDialogs.toast(context, "Failed to post comment: " + e.message);
UIDialogs.toast(context, context.getString(R.string.failed_to_post_comment) + " ${e.message}");
}
};
@@ -4,13 +4,21 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.dp
import com.futo.platformplayer.views.buttons.BigButton
import java.lang.reflect.Field
import java.lang.reflect.Method
class ButtonField : LinearLayout, IField {
@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class FormFieldButton(val drawable: Int = 0)
class ButtonField : BigButton, IField {
override var descriptor: FormField? = null;
private var _obj : Any? = null;
private var _method : Method? = null;
@@ -26,17 +34,22 @@ class ButtonField : LinearLayout, IField {
return null;
};
private val _title : TextView;
private val _subtitle : TextView;
//private val _title : TextView;
//private val _subtitle : TextView;
override val onChanged = Event2<IField, Any>();
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_button, this);
_title = findViewById(R.id.field_title);
_subtitle = findViewById(R.id.field_subtitle);
//inflate(context, R.layout.field_button, this);
//_title = findViewById(R.id.field_title);
//_subtitle = findViewById(R.id.field_subtitle);
setOnClickListener {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
val dp5 = 5.dp(context.resources);
setMargins(0, dp5, 0, dp5)
};
super.onClick.subscribe {
if(_method?.parameterCount == 1)
_method?.invoke(_obj, context);
else if(_method?.parameterCount == 2)
@@ -51,13 +64,17 @@ class ButtonField : LinearLayout, IField {
this._obj = obj;
val attrField = method.getAnnotation(FormField::class.java);
val attrButtonField = method.getAnnotation(FormFieldButton::class.java);
if(attrField != null) {
_title.text = attrField.title;
_subtitle.text = attrField.subtitle;
super.withPrimaryText(context.getString(attrField.title))
.withSecondaryText(if (attrField.subtitle != -1) context.getString(attrField.subtitle) else "")
.withSecondaryTextMaxLines(2);
descriptor = attrField;
}
else
_title.text = method.name;
super.withPrimaryText(method.name);
if(attrButtonField != null)
super.withIcon(attrButtonField.drawable, false);
return this;
}
@@ -37,7 +37,7 @@ class DropdownField : TableRow, IField {
override val onChanged = Event2<IField, Any>();
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_dropdown, this);
_spinner = findViewById(R.id.field_spinner);
_title = findViewById(R.id.field_title);
@@ -77,17 +77,17 @@ class DropdownField : TableRow, IField {
return this;
}
override fun fromField(obj : Any, field : Field, formField: FormField?) : DropdownField {
override fun fromField(obj: Any, field: Field, formField: FormField?) : DropdownField {
this._field = field;
this._obj = obj;
val attrField = formField ?: field.getAnnotation(FormField::class.java);
if(attrField != null) {
_title.text = attrField.title;
_title.text = context.getString(attrField.title);
descriptor = attrField;
if(attrField.subtitle.isNotBlank()) {
_description.text = attrField.subtitle;
if(attrField.subtitle != -1) {
_description.text = context.getString(attrField.subtitle);
_description.visibility = View.VISIBLE;
}
else
@@ -6,7 +6,7 @@ import java.lang.reflect.Field
@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class FormField(val title : String, val type : String, val subtitle : String = "", val order : Int = 0, val id : String = "")
annotation class FormField(val title: Int, val type: String, val subtitle: Int = -1, val order: Int = 0, val id: String = "")
interface IField {
var descriptor: FormField?;
@@ -101,8 +101,8 @@ class GroupField : LinearLayout, IField {
val attrField = formField ?: field.getAnnotation(FormField::class.java); //TODO: Get this to work as default
if(attrField != null) {
_title.text = attrField.title;
_subtitle.text = attrField.subtitle;
_title.text = context.getString(attrField.title);
_subtitle.text = if (attrField.subtitle != -1) context.getString(attrField.subtitle) else "";
descriptor = attrField;
}
else
@@ -42,7 +42,7 @@ class ReadOnlyTextField : TableRow, IField {
val attrField = formField ?: field.getAnnotation(FormField::class.java);
if(attrField != null) {
_title.text = attrField.title;
_title.text = context.getString(attrField.title);
descriptor = attrField;
}
else
@@ -60,7 +60,7 @@ class ReadOnlyTextField : TableRow, IField {
val attrField = formField ?: field.getAnnotation(FormField::class.java);
if(attrField != null) {
_title.text = attrField.title;
_title.text = context.getString(attrField.title);
descriptor = attrField;
}
else
@@ -64,16 +64,16 @@ class ToggleField : TableRow, IField {
val attrField = formField ?: field.getAnnotation(FormField::class.java);
if(attrField != null) {
_title.text = attrField.title;
_title.text = context.getString(attrField.title);
descriptor = attrField;
}
else
_title.text = field.name;
if(attrField?.subtitle?.isEmpty() != false)
if(attrField == null || attrField.subtitle == -1)
_description.visibility = View.GONE;
else {
_description.text = attrField.subtitle;
_description.text = context.getString(attrField.subtitle);
_description.visibility = View.VISIBLE;
}
@@ -110,9 +110,9 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed.text = "${_download.downloadSpeed.toHumanBytesSpeed()} ${(_download.progress * 100).toInt()}%";
_videoState.text = if(!Settings.instance.downloads.shouldDownload())
"Waiting for unmetered" + (if(!_download.error.isNullOrEmpty()) "\n(Last error: " + _download.error + ")" else "");
context.getString(R.string.waiting_for_unmetered) + (if(!_download.error.isNullOrEmpty()) "\n(" + context.getString(R.string.last_error) + ": " + _download.error + ")" else "");
else if(_download.state == VideoDownload.State.QUEUED && !_download.error.isNullOrEmpty())
_download.state.toString() + "\n(Last error: " + _download.error + ")";
_download.state.toString() + "\n(" + context.getString(R.string.last_error) + ": " + _download.error + ")";
else
_download.state.toString();
_videoState.setTextColor(Color.GRAY);
@@ -123,7 +123,7 @@ class ActiveDownloadItem: LinearLayout {
};
VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: "Error";
_videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE;
_videoSpeed.visibility = GONE;
}
@@ -204,9 +204,9 @@ class LiveChatOverlay : LinearLayout {
_window = window;
if(viewerCount != null)
_textViewers.text = viewerCount.toHumanNumber() + " viewers";
_textViewers.text = viewerCount.toHumanNumber() + " " + context.getString(R.string.viewers);
else if(manager != null)
_textViewers.text = manager.viewCount.toHumanNumber() + " viewers";
_textViewers.text = manager.viewCount.toHumanNumber() + " " + context.getString(R.string.viewers);
else
_textViewers.text = "";
@@ -239,7 +239,7 @@ class LiveChatOverlay : LinearLayout {
}
else if(event is LiveEventViewCount)
scope.launch(Dispatchers.Main) {
_textViewers.text = "${event.viewCount.toLong().toHumanNumber()} viewers";
_textViewers.text = "${event.viewCount.toLong().toHumanNumber()} " + context.getString(R.string.viewers);
}
else
handleLiveEvent(event);
@@ -25,13 +25,13 @@ class QueueEditorOverlay : LinearLayout {
_editor.onVideoRemoved.subscribe { v -> StatePlayer.instance.removeFromQueue(v) }
_editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) }
_topbar.setInfo("Queue", "");
_topbar.setInfo(context.getString(R.string.queue), "");
}
fun updateQueue() {
val queue = StatePlayer.instance.getQueue();
_editor.setVideos(queue, true);
_topbar.setInfo("Queue", "${queue.size} videos");
_topbar.setInfo(context.getString(R.string.queue), "${queue.size} " + context.getString(R.string.videos));
}
fun cleanup() {

Some files were not shown because too many files have changed in this diff Show More