Compare commits

...

41 Commits

Author SHA1 Message Date
Kelvin aeaaace3a4 Subscription settings from creators tab 2023-11-02 23:42:51 +01:00
Kelvin e6997004ff Fix new user crash, show/hide subscription settings button on change, raise import limit to 90 2023-11-02 23:22:42 +01:00
Kelvin 5e1896b7f2 Stable ref 2023-11-02 22:52:29 +01:00
Kelvin 88ca90c13a Notification improvements, Polycentric subscription parallelization, Cache load parallelization 2023-11-02 22:23:24 +01:00
Kelvin f8ee340499 Creator sort options views and watchtime, subscription header ordered by views, view/watchtime tracking for subscriptions, optional view/watchtime metrics in creator tab, cache channel results if subscribed, update subs only if older than 5 min 2023-11-02 20:21:26 +01:00
Kelvin 93f5260e20 Working smart subscriptions, Direct url through search, channel content cache trimming, skippable and skip chapter support, reinstall button for embedded plugins 2023-11-01 20:32:51 +01:00
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
237 changed files with 10105 additions and 1995 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Thank you for your interest in contributing! This document outlines how you can
### License ### 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 ### How to Contribute
+2 -2
View File
@@ -38,6 +38,7 @@
android:enabled="true" /> android:enabled="true" />
<receiver android:name=".receivers.MediaControlReceiver" /> <receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
@@ -182,9 +183,8 @@
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity" android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application> </application>
+6
View File
@@ -31,6 +31,12 @@ let Type = {
RAW: 0, RAW: 0,
HTML: 1, HTML: 1,
MARKUP: 2 MARKUP: 2
},
Chapter: {
NORMAL: 0,
SKIPPABLE: 5,
SKIP: 6
} }
}; };
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
return "${value} ${unit}"; return "${value} ${unit}";
}; };
fun Int.toHumanTimeIndicator(abs: Boolean = false) : String {
var value = this;
var unit = "s";
if(abs) value = abs(value);
if(value >= secondsInHour) {
value = (this / secondsInHour).toInt();
if(abs) value = abs(value);
unit = "hr" + (if(value > 1) "s" else "");
}
else if(value >= secondsInMinute) {
value = (this / secondsInMinute).toInt();
if(abs) value = abs(value);
unit = "min";
}
return "${value}${unit}";
}
fun Long.toHumanTime(isMs: Boolean): String { fun Long.toHumanTime(isMs: Boolean): String {
var scaler = 1; var scaler = 1;
@@ -35,4 +35,8 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
fun Protocol.Claim.resolveChannelUrl(): String? { fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) 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.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -44,9 +45,10 @@ class Settings : FragmentedStorageFileJson() {
val onTabsChanged = Event0(); val onTabsChanged = Event0();
@FormField( @FormField(
"Manage Polycentric identity", FieldForm.BUTTON, R.string.manage_polycentric_identity, FieldForm.BUTTON,
"Manage your Polycentric identity", -3 R.string.manage_your_polycentric_identity, -4
) )
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() { fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) { if (StatePolycentric.instance.processHandle != null) {
@@ -58,22 +60,38 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Open FAQ", FieldForm.BUTTON, R.string.show_faq, FieldForm.BUTTON,
"Get answers to common questions", -2 R.string.get_answers_to_common_questions, -3
) )
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() { fun openFAQ() {
try { 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); SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} }
/*
@FormField( @FormField(
"Submit feedback", FieldForm.BUTTON, R.string.submit_feedback, FieldForm.BUTTON,
"Give feedback on the application", -1 R.string.give_feedback_on_the_application, -1
) )
@FormFieldButton(R.drawable.ic_bug)
fun submitFeedback() { fun submitFeedback() {
try { try {
val i = Intent(Intent.ACTION_VIEW); val i = Intent(Intent.ACTION_VIEW);
@@ -87,12 +105,13 @@ class Settings : FragmentedStorageFileJson() {
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} }*/
@FormField( @FormField(
"Manage Tabs", FieldForm.BUTTON, R.string.manage_tabs, FieldForm.BUTTON,
"Change tabs visible on the home screen", -1 R.string.change_tabs_visible_on_the_home_screen, -1
) )
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() { fun manageTabs() {
try { try {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -103,11 +122,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(); var home = HomeSettings();
@Serializable @Serializable
class HomeSettings { class HomeSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1; var homeFeedStyle: Int = 1;
@@ -119,16 +138,16 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Search", "group", "", 2) @FormField(R.string.search, "group", -1, 2)
var search = SearchSettings(); var search = SearchSettings();
@Serializable @Serializable
class SearchSettings { class SearchSettings {
@FormField("Search History", FieldForm.TOGGLE, "", 4) @FormField(R.string.search_history, FieldForm.TOGGLE, -1, 4)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true; var searchHistory: Boolean = true;
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1; var searchFeedStyle: Int = 1;
@@ -141,11 +160,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(); var subscriptions = SubscriptionsSettings();
@Serializable @Serializable
class SubscriptionsSettings { class SubscriptionsSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1; var subscriptionsFeedStyle: Int = 1;
@@ -156,11 +175,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; 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) @Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true; 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) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -176,26 +195,32 @@ 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) @DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3; var subscriptionConcurrency: Int = 3;
fun getSubscriptionsConcurrency() : Int { fun getSubscriptionsConcurrency() : Int {
return threadIndexToCount(subscriptionConcurrency); return threadIndexToCount(subscriptionConcurrency);
} }
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9)
var showWatchMetrics: Boolean = false;
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
var allowPlaytimeTracking: Boolean = true;
} }
@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(); var playback = PlaybackSettings();
@Serializable @Serializable
class PlaybackSettings { class PlaybackSettings {
@FormField("Primary Language", FieldForm.DROPDOWN, "", 0) @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.languages) @DropdownFieldOptionsId(R.array.languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage]; 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) @DropdownFieldOptionsId(R.array.playback_speeds)
var defaultPlaybackSpeed: Int = 3; var defaultPlaybackSpeed: Int = 3;
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) { fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@@ -211,29 +236,29 @@ class Settings : FragmentedStorageFileJson() {
else -> 1.0f; else -> 1.0f;
}; };
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2) @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredQuality: Int = 0; 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) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredMeteredQuality: Int = 0; var preferredMeteredQuality: Int = 0;
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality); fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality); fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount(); 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) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredPreviewQuality: Int = 5; var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); 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) @DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2; var autoRotate: Int = 2;
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); 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) @DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0; var autoRotateDeadZone: Int = 0;
@@ -241,19 +266,19 @@ class Settings : FragmentedStorageFileJson() {
return autoRotateDeadZone * 5; return autoRotateDeadZone * 5;
} }
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6) @FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@DropdownFieldOptionsId(R.array.player_background_behavior) @DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2; var backgroundPlay: Int = 2;
fun isBackgroundContinue() = backgroundPlay == 1; fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2; 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) @DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1; 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; var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{ fun shouldResumePreview(previewedPosition: Long): Boolean{
@@ -265,12 +290,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(); var downloads = Downloads();
@Serializable @Serializable
class Downloads { 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) @DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0; var whenDownload: Int = 0;
@@ -283,21 +308,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) @DropdownFieldOptionsId(R.array.preferred_video_download)
var preferredVideoQuality: Int = 4; var preferredVideoQuality: Int = 4;
fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality); 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) @DropdownFieldOptionsId(R.array.preferred_audio_download)
var preferredAudioQuality: Int = 1; var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0; 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) @Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true; 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) @DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3; var byteRangeConcurrency: Int = 3;
fun getByteRangeThreadCount(): Int { fun getByteRangeThreadCount(): Int {
@@ -305,20 +330,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(); var browsing = Browsing();
@Serializable @Serializable
class Browsing { 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) @Serializable(with = FlexibleBooleanSerializer::class)
var videoCache: Boolean = true; var videoCache: Boolean = true;
} }
@FormField("Casting", "group", "Configure casting", 7) @FormField(R.string.casting, "group", R.string.configure_casting, 7)
var casting = Casting(); var casting = Casting();
@Serializable @Serializable
class Casting { class Casting {
@FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0) @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enable_casting, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true; var enabled: Boolean = true;
@@ -340,24 +365,24 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField("Logging", FieldForm.GROUP, "", 8) @FormField(R.string.logging, FieldForm.GROUP, -1, 8)
var logging = Logging(); var logging = Logging();
@Serializable @Serializable
class Logging { class Logging {
@FormField("Log Level", FieldForm.DROPDOWN, "", 0) @FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.log_levels) @DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0; var logLevel: Int = 0;
@FormField( @FormField(
"Submit logs", FieldForm.BUTTON, R.string.submit_logs, FieldForm.BUTTON,
"Submit logs to help us narrow down issues", 1 R.string.submit_logs_to_help_us_narrow_down_issues, 1
) )
fun submitLogs() { fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
if (!Logger.submitLogs()) { if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) { 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) { } catch (e: Throwable) {
@@ -369,40 +394,40 @@ class Settings : FragmentedStorageFileJson() {
@FormField("Announcement", FieldForm.GROUP, "", 10) @FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
var announcementSettings = AnnouncementSettings(); var announcementSettings = AnnouncementSettings();
@Serializable @Serializable
class AnnouncementSettings { class AnnouncementSettings {
@FormField( @FormField(
"Reset announcements", FieldForm.BUTTON, R.string.reset_announcements, FieldForm.BUTTON,
"Reset hidden announcements", 1 R.string.reset_hidden_announcements, 1
) )
fun resetAnnouncements() { fun resetAnnouncements() {
StateAnnouncement.instance.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 @Transient
var plugins = Plugins(); var plugins = Plugins();
@Serializable @Serializable
class Plugins { 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; var clearCookiesOnLogout: Boolean = true;
@FormField( @FormField(
"Clear Cookies", FieldForm.BUTTON, R.string.clear_cookies, FieldForm.BUTTON,
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1 R.string.clears_in_app_browser_cookies, 1
) )
fun clearCookies() { fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@FormField( @FormField(
"Reinstall Embedded Plugins", FieldForm.BUTTON, R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1 R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
) )
fun reinstallEmbedded() { fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -411,7 +436,7 @@ class Settings : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateApp.instance.contextOrNull?.let { 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) { } catch (ex: Exception) {
@@ -426,7 +451,7 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField("External Storage", FieldForm.GROUP, "", 12) @FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
var storage = Storage(); var storage = Storage();
@Serializable @Serializable
class Storage { class Storage {
@@ -438,13 +463,13 @@ class Settings : FragmentedStorageFileJson() {
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri()); fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri()); 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() { fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it); 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() { fun changeStorageDownload() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it); StateApp.instance.changeExternalDownloadDirectory(it);
@@ -453,19 +478,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(); var autoUpdate = AutoUpdate();
@Serializable @Serializable
class AutoUpdate { class AutoUpdate {
@FormField("Check", FieldForm.DROPDOWN, "", 0) @FormField(R.string.check, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.auto_update_when_array) @DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0; 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) @DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0; 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) @DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0; var whenDownload: Int = 0;
@@ -483,8 +508,8 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Manual check", FieldForm.BUTTON, R.string.manual_check, FieldForm.BUTTON,
"Manually check for updates", 3 R.string.manually_check_for_updates, 3
) )
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
@@ -496,19 +521,20 @@ class Settings : FragmentedStorageFileJson() {
try { try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}"))) it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
UIDialogs.toast(it, "Failed to show store."); UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
} }
} }
} }
} }
@FormField( @FormField(
"View changelog", FieldForm.BUTTON, R.string.view_changelog, FieldForm.BUTTON,
"Review the current and past changelogs", 4 R.string.review_the_current_and_past_changelogs, 4
) )
fun viewChangelog() { fun viewChangelog() {
UIDialogs.toast("Retrieving changelog");
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch; val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
@@ -525,8 +551,8 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Remove Cached Version", FieldForm.BUTTON, R.string.remove_cached_version, FieldForm.BUTTON,
"Remove the last downloaded version", 5 R.string.remove_the_last_downloaded_version, 5
) )
fun removeCachedVersion() { fun removeCachedVersion() {
StateApp.withContext { StateApp.withContext {
@@ -543,7 +569,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Backup", FieldForm.GROUP, "", 13) @FormField(R.string.backup, FieldForm.GROUP, -1, 13)
var backup = Backup(); var backup = Backup();
@Serializable @Serializable
class Backup { class Backup {
@@ -553,58 +579,58 @@ class Settings : FragmentedStorageFileJson() {
var autoBackupPassword: String? = null; var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != 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"; 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() { fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) { UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings(); 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() { fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!! val activity = SettingsActivity.getActivity()!!
if(!StateBackup.hasAutomaticBackup()) 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 else
UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope); 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() { fun export() {
StateBackup.startExternalBackup(); StateBackup.startExternalBackup();
} }
} }
@FormField("Payment", FieldForm.GROUP, "", 14) @FormField(R.string.payment, FieldForm.GROUP, -1, 14)
var payment = Payment(); var payment = Payment();
@Serializable @Serializable
class Payment { class Payment {
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1) @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid"; 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() { fun clearPayment() {
StatePayment.instance.clearLicenses(); StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let { 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(); it.reloadSettings();
} }
} }
} }
@FormField("Info", FieldForm.GROUP, "", 15) @FormField(R.string.info, FieldForm.GROUP, -1, 15)
var info = Info(); var info = Info();
@Serializable @Serializable
class Info { class Info {
@FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code") @FormField(R.string.version_code, FieldForm.READONLYTEXT, -1, 1, "code")
var versionCode = BuildConfig.VERSION_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; 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; var versionType = BuildConfig.BUILD_TYPE;
} }
@@ -615,6 +641,7 @@ class Settings : FragmentedStorageFileJson() {
companion object { companion object {
private const val TAG = "Settings"; private const val TAG = "Settings";
const val URL_FAQ = "https://grayjay.app/faq.html";
private var _isFirst = true; private var _isFirst = true;
@@ -2,14 +2,24 @@ package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@@ -17,6 +27,7 @@ import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
@@ -27,28 +38,30 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@Serializable() @Serializable()
class SettingsDev : FragmentedStorageFileJson() { class SettingsDev : FragmentedStorageFileJson() {
@FormField("Developer Mode", FieldForm.TOGGLE, "", 0) @FormField(R.string.developer_mode, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var developerMode: Boolean = false; var developerMode: Boolean = false;
@FormField("Development Server", FieldForm.GROUP, @FormField(R.string.development_server, FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1) R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 1)
val devServerSettings: DeveloperServerFields = DeveloperServerFields(); val devServerSettings: DeveloperServerFields = DeveloperServerFields();
@Serializable @Serializable
class DeveloperServerFields { 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) @Serializable(with = FlexibleBooleanSerializer::class)
var devServerOnBoot: Boolean = false; var devServerOnBoot: Boolean = false;
@FormField("Start Server", FieldForm.BUTTON, @FormField(R.string.start_server, FieldForm.BUTTON,
"Starts a DevServer on port 11337, may expose vulnerabilities.", 1) R.string.starts_a_devServer_on_port_11337_may_expose_vulnerabilities, 1)
fun startServer() { fun startServer() {
StateDeveloper.instance.runServer(); StateDeveloper.instance.runServer();
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
@@ -57,45 +70,57 @@ class SettingsDev : FragmentedStorageFileJson() {
} }
} }
@FormField("Experimental", FieldForm.GROUP, @FormField(R.string.experimental, FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2) R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 2)
val experimentalSettings: ExperimentalFields = ExperimentalFields(); val experimentalSettings: ExperimentalFields = ExperimentalFields();
@Serializable @Serializable
class ExperimentalFields { class ExperimentalFields {
@FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0) @FormField(R.string.background_subscription_testing, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var backgroundSubscriptionFetching: Boolean = false; var backgroundSubscriptionFetching: Boolean = false;
} }
@FormField("Crash Me", FieldForm.BUTTON, @FormField(R.string.crash_me, FieldForm.BUTTON,
"Crashes the application on purpose", 2) R.string.crashes_the_application_on_purpose, 2)
fun crashMe() { fun crashMe() {
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!"); throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
} }
@FormField("Delete Announcements", FieldForm.BUTTON, @FormField(R.string.delete_announcements, FieldForm.BUTTON,
"Delete all announcements", 2) R.string.delete_all_announcements, 2)
fun deleteAnnouncements() { fun deleteAnnouncements() {
StateAnnouncement.instance.deleteAllAnnouncements(); StateAnnouncement.instance.deleteAllAnnouncements();
} }
@FormField("Clear Cookies", FieldForm.BUTTON, @FormField(R.string.clear_cookies, FieldForm.BUTTON,
"Clear all cook from the CookieManager", 2) R.string.clear_all_cookies_from_the_cookieManager, 2)
fun clearCookies() { fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance() val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!;
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
.build();
wm.enqueue(req);
}
@Contextual @Contextual
@Transient @Transient
@FormField("V8 Benchmarks", FieldForm.GROUP, @FormField(R.string.v8_benchmarks, FieldForm.GROUP,
"Various benchmarks using the integrated V8 engine", 3) R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
val v8Benchmarks: V8Benchmarks = V8Benchmarks(); val v8Benchmarks: V8Benchmarks = V8Benchmarks();
class V8Benchmarks { class V8Benchmarks {
@FormField( @FormField(
"Test V8 Creation speed", FieldForm.BUTTON, R.string.test_v8_creation_speed, FieldForm.BUTTON,
"Tests V8 creation times and running", 1 R.string.tests_v8_creation_times_and_running, 1
) )
fun testV8Creation() { fun testV8Creation() {
var plugin: V8Plugin? = null; var plugin: V8Plugin? = null;
@@ -137,8 +162,8 @@ class SettingsDev : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Test V8 Communication speed", FieldForm.BUTTON, R.string.test_v8_communication_speed, FieldForm.BUTTON,
"Tests V8 communication speeds", 2 R.string.tests_v8_communication_speeds, 4
) )
fun testV8RunSpeeds() { fun testV8RunSpeeds() {
var plugin: V8Plugin? = null; var plugin: V8Plugin? = null;
@@ -182,12 +207,12 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual @Contextual
@Transient @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(); val v8ScriptTests: V8ScriptTests = V8ScriptTests();
class V8ScriptTests { class V8ScriptTests {
@Contextual @Contextual
private var _currentPlugin : JSClient? = null; 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() { fun testV8Init() {
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
try { try {
@@ -203,7 +228,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() { fun testV8Home() {
runTestPlugin(_currentPlugin) { runTestPlugin(_currentPlugin) {
var home: IPager<IPlatformContent>? = null; var home: IPager<IPlatformContent>? = null;
@@ -269,27 +294,36 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual @Contextual
@Transient @Transient
@FormField("Other", FieldForm.GROUP, "Others...", 5) @FormField(R.string.other, FieldForm.GROUP, R.string.others_ellipsis, 5)
val otherTests: OtherTests = OtherTests(); val otherTests: OtherTests = OtherTests();
class 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() { fun clearDownloads() {
StateDownloads.instance.getDownloading().forEach { StateDownloads.instance.getDownloading().forEach {
StateDownloads.instance.removeDownload(it); 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() { fun clearDownloaded() {
StateDownloads.instance.getDownloadedVideos().forEach { StateDownloads.instance.getDownloadedVideos().forEach {
StateDownloads.instance.deleteCachedVideo(it.id); 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() { fun cleanupDownloads() {
StateDownloads.instance.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?) { fun fillStorage(context: Context, scope: CoroutineScope?) {
val gigabuffer = ByteArray(1024 * 1024 * 128); val gigabuffer = ByteArray(1024 * 1024 * 128);
var count: Long = 0; var count: Long = 0;
@@ -100,12 +100,12 @@ class UIDialogs {
dialog.show(); dialog.show();
}; };
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck) 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.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("Cancel", {}), //To nothing UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action("Override", { UIDialogs.Action(context.getString(R.string.override), {
dialogAction(); dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS), }, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", { UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope); UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
else { else {
@@ -211,10 +211,10 @@ class UIDialogs {
(if(ex != null ) "${ex.message}" else ""), (if(ex != null ) "${ex.message}" else ""),
if(ex is PluginException) ex.code else null, if(ex is PluginException) ex.code else null,
0, 0,
UIDialogs.Action("Retry", { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY), }, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Close", { UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE) }, UIDialogs.ActionStyle.NONE)
); );
@@ -226,15 +226,15 @@ class UIDialogs {
} }
fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) { fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY) val retryButtonAction = Action(context.getString(R.string.retry), retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT) val closeButtonAction = Action(context.getString(R.string.close), closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, closeButtonAction, retryButtonAction) 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) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
} }
@@ -1,8 +1,12 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.content.ContentResolver import android.content.ContentResolver
import android.graphics.Color
import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource 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.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
@@ -45,6 +50,64 @@ class UISlideOverlays {
menu.show(); 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 {
subscription.save();
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? { fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@@ -64,22 +127,22 @@ class UISlideOverlays {
val subtitleSources = video.subtitles; val subtitleSources = video.subtitles;
if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) { 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; return null;
} }
if(!VideoHelper.isDownloadable(video)) { if(!VideoHelper.isDownloadable(video)) {
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}"); 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; return null;
} }
items.add(SlideUpMenuGroup(container.context, "Video", videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { 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; selectedVideo = null;
menu?.selectOption(videoSources, "none"); menu?.selectOption(videoSources, "none");
if(selectedAudio != null || !requiresAudio) if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false)) + }, false)) +
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
@@ -88,7 +151,7 @@ class UISlideOverlays {
selectedVideo = it as IVideoUrlSource; selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it); menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio) if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false) }, false)
}).flatten().toList() }).flatten().toList()
)); ));
@@ -100,13 +163,13 @@ class UISlideOverlays {
audioSources?.let { audioSources -> 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) } .filter { VideoHelper.isDownloadable(it) }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource; selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it); menu?.selectOption(audioSources, it);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false); }, false);
})); }));
val asources = audioSources; val asources = audioSources;
@@ -125,7 +188,7 @@ class UISlideOverlays {
//ContentResolver is required for subtitles.. //ContentResolver is required for subtitles..
if(contentResolver != null) { 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 { .map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
if (selectedSubtitle == 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) { if(selectedVideo != null) {
menu.selectOption(videoSources, selectedVideo); menu.selectOption(videoSources, selectedVideo);
@@ -148,7 +211,7 @@ class UISlideOverlays {
audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); }; audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); };
} }
if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) { if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) {
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
menu.onOK.subscribe { menu.onOK.subscribe {
@@ -185,7 +248,7 @@ class UISlideOverlays {
} }
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) { fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
val handleUnknownDownload: ()->Unit = { val handleUnknownDownload: ()->Unit = {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate) StateDownloads.instance.download(video, px, bitrate)
}; };
}; };
@@ -195,7 +258,7 @@ class UISlideOverlays {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
if(scope != null) { 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) { scope.launch(Dispatchers.IO) {
try { try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await(); val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
@@ -209,7 +272,7 @@ class UISlideOverlays {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
withContext(Dispatchers.Main) { 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(); handleUnknownDownload();
loader.hide(true); loader.hide(true);
} }
@@ -220,7 +283,7 @@ class UISlideOverlays {
} }
} }
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) { 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); StateDownloads.instance.download(playlist, px, bitrate);
}; };
} }
@@ -232,7 +295,7 @@ class UISlideOverlays {
var targetBitrate: Long = 0; var targetBitrate: Long = 0;
val resolutions = listOf( 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>("480P", "720x480", 720*480),
Triple<String, String, Long>("720P", "1280x720", 1280*720), Triple<String, String, Long>("720P", "1280x720", 1280*720),
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080), Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
@@ -240,23 +303,23 @@ class UISlideOverlays {
Triple<String, String, Long>("2160P", "3840x2160", 3840*2160) 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, { SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
targetPxSize = it.third; targetPxSize = it.third;
menu?.selectOption("Video", it.third); menu?.selectOption("Video", it.third);
}, false) }, false)
})); }));
items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf( items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, { SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
targetBitrate = 1; targetBitrate = 1;
menu?.selectOption("Bitrate", 1); menu?.selectOption("Bitrate", 1);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false), }, 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; targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999); menu?.selectOption("Bitrate", 9999999);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false) }, false)
))); )));
@@ -277,12 +340,12 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) { if(Settings.instance.downloads.isHighBitrateDefault()) {
targetBitrate = 9999999; targetBitrate = 9999999;
menu.selectOption("Bitrate", 9999999); menu.selectOption("Bitrate", 9999999);
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
else { else {
targetBitrate = 1; targetBitrate = 1;
menu.selectOption("Bitrate", 1); menu.selectOption("Bitrate", 1);
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
menu.onOK.subscribe { menu.onOK.subscribe {
@@ -304,14 +367,14 @@ class UISlideOverlays {
return overlay; 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 items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
if (lastUpdated != null) { if (lastUpdated != null) {
items.add( items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", 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} videos", "", 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); StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -322,23 +385,23 @@ class UISlideOverlays {
val allPlaylists = StatePlaylists.instance.getPlaylists(); val allPlaylists = StatePlaylists.instance.getPlaylists();
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, "Actions", "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide", (listOf(
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }), 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),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", { showDownloadVideoOverlay(video, container, true); }, false))
{ showDownloadVideoOverlay(video, container, true); }, false) + actions)
)) ));
items.add( items.add(
SlideUpMenuGroup(container.context, "Add To", "addto", SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue", 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); }), { 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)); }) { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); })
)); ));
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) { 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); StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -346,9 +409,9 @@ class UISlideOverlays {
} }
if(playlistItems.size > 0) 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) { if (lastUpdated != null) {
items.add( items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", 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} videos", "", 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); StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -373,18 +436,18 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add( items.add(
SlideUpMenuGroup(container.context, "Other", "other", SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue", 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); }), { 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)); }), { 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)) { showDownloadVideoOverlay(video, container, true); }, false))
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) { 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); StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -392,9 +455,9 @@ class UISlideOverlays {
} }
if(playlistItems.size > 0) 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 { 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(), "", "", { .map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn); btn.handler?.invoke(btn);
}, true) as View }.toTypedArray() ?: arrayOf(), }, true) as View }.toTypedArray() ?: arrayOf(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "Decide which buttons should be pinned", "", { 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, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
val selected = it val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null } .filter { it != null }
@@ -425,7 +488,7 @@ class UISlideOverlays {
}, false)) }, false))
).flatten().toTypedArray(); ).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) { 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; 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, { options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
if(overlay!!.selectOption(null, it.second, true, true)) { if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second)) if(!selection.contains(it.second))
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
} }
} }
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
fun String.isHttpUrl(): Boolean {
return _regexUrl.matchEntire(this) != null;
}
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})"); private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
fun String.isHexColor(): Boolean { fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this); return _regexHexColor.matches(this);
@@ -96,8 +96,8 @@ class AddSourceActivity : AppCompatActivity() {
var url = intent?.dataString; var url = intent?.dataString;
if(url == null) if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null, UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY)); 0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
else { else {
if(url.startsWith("vfuto://")) if(url.startsWith("vfuto://"))
url = "https://" + url.substring("vfuto://".length); url = "https://" + url.substring("vfuto://".length);
@@ -129,14 +129,14 @@ class AddSourceActivity : AppCompatActivity() {
Logger.e(TAG, "Failed decode config", ex); Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error, 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)); 0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
}; };
return@launch; return@launch;
} catch(ex: Exception) { } catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex); Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) { 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; return@launch;
} }
@@ -152,7 +152,7 @@ class AddSourceActivity : AppCompatActivity() {
} catch (ex: Exception) { } catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex); Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) { 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; return@launch;
} }
@@ -175,8 +175,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView( _sourcePermissions.addView(
SourceInfoView(this, SourceInfoView(this,
R.drawable.ic_language, R.drawable.ic_language,
"URL Access", getString(R.string.url_access),
"The plugin will have access to the following domains", getString(R.string.the_plugin_will_have_access_to_the_following_domains),
config.allowUrls, true) config.allowUrls, true)
) )
@@ -184,8 +184,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView( _sourcePermissions.addView(
SourceInfoView(this, SourceInfoView(this,
R.drawable.ic_code, R.drawable.ic_code,
"Eval Access", getString(R.string.eval_access),
"The plugin will have access to eval capability (remote injection)", getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
config.allowUrls, true) config.allowUrls, true)
) )
@@ -16,13 +16,14 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
lateinit var _buttonPlugins: BigButton;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let { scanResult?.let {
val content = it.contents val content = it.contents
if (content == null) { if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code") UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let return@let
} }
@@ -31,7 +32,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} else if (content.startsWith("grayjay://plugin/")) { } else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length) content.substring("grayjay://plugin/".length)
} else { } else {
UIDialogs.toast(this, "Not a plugin URL") UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@let; return@let;
} }
@@ -51,6 +52,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR = findViewById(R.id.option_qr); _buttonQR = findViewById(R.id.option_qr);
_buttonURL = findViewById(R.id.option_url); _buttonURL = findViewById(R.id.option_url);
_buttonPlugins = findViewById(R.id.option_plugins);
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
finish(); finish();
@@ -59,7 +61,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR.onClick.subscribe { _buttonQR.onClick.subscribe {
val integrator = IntentIntegrator(this); val integrator = IntentIntegrator(this);
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR Code") integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true);
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
@@ -69,12 +71,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
_buttonURL.onClick.subscribe { _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); _buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context"; val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?"; 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" + 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" + "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() { private fun submitFile() {
if (_submitted) { 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; return;
} }
val file = _file; val file = _file;
if (file == null) { 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; return;
} }
@@ -101,14 +101,14 @@ class ExceptionActivity : AppCompatActivity() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (id == null) { if (id == null) {
try { 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) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} else { } else {
_submitted = true; _submitted = true;
file.delete(); 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); val i = Intent(Intent.ACTION_SEND);
i.type = "text/plain"; i.type = "text/plain";
i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org")); 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); 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) { } catch (e: Throwable) {
//Ignored //Ignored
@@ -459,6 +459,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "View Received: " + targetData); Logger.i(TAG, "View Received: " + targetData);
} }
} }
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"TAB" -> { "TAB" -> {
when(intent.getStringExtra("TAB")){ when(intent.getStringExtra("TAB")){
"Sources" -> { "Sources" -> {
@@ -478,13 +482,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(targetData.startsWith("grayjay://license/")) { if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData)) 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) if(fragCurrent is BuyFragment)
closeSegment(fragCurrent); closeSegment(fragCurrent);
} }
else else
UIDialogs.toast("Invalid license format"); UIDialogs.toast(getString(R.string.invalid_license_format));
} }
else if(targetData.startsWith("grayjay://plugin/")) { else if(targetData.startsWith("grayjay://plugin/")) {
@@ -499,7 +503,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown content format [${targetData}]", getString(R.string.unknown_content_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -509,7 +513,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown file format [${targetData}]", getString(R.string.unknown_file_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -519,7 +523,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown Polycentric format [${targetData}]", getString(R.string.unknown_polycentric_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -529,7 +533,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown url format [${targetData}]", getString(R.string.unknown_url_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -538,7 +542,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
catch(ex: Throwable) { 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) { val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore "Playlist" -> StatePlaylists.instance.playlistStore
else -> { else -> {
UIDialogs.toast("Unknown reconstruction type ${type}", false); UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
return; return;
}; };
}; };
@@ -646,7 +650,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
catch(ex: Exception) { catch(ex: Exception) {
Logger.e(TAG, ex.message, ex); 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); sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent; 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); val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
_imageQR.setImageBitmap(qrCodeBitmap); _imageQR.setImageBitmap(qrCodeBitmap);
} catch (e: Exception) { } 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; _imageQR.visibility = View.INVISIBLE;
_textQR.visibility = View.INVISIBLE; _textQR.visibility = View.INVISIBLE;
} }
@@ -63,12 +63,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
type = "text/plain"; type = "text/plain";
putExtra(Intent.EXTRA_TEXT, _exportBundle); putExtra(Intent.EXTRA_TEXT, _exportBundle);
} }
startActivity(Intent.createChooser(shareIntent, "Share Text")); startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
}; };
_buttonCopy.onClick.subscribe { _buttonCopy.onClick.subscribe {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager; 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); clipboard.setPrimaryClip(clip);
}; };
} }
@@ -54,7 +54,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try { try {
val username = _profileName.text.toString(); val username = _profileName.text.toString();
if (username.length < 3) { 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; return@setOnClickListener;
} }
@@ -68,16 +68,18 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to create profile .", e); Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch; return@launch;
} finally { } finally {
_creating = false; _creating = false;
} }
try { try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers(); processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } 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) { 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()); this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt());
}; };
profileButton.withPrimaryText(systemState.username); profileButton.withPrimaryText(systemState.username);
profileButton.withSecondaryText("Sign in to this identity"); profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
profileButton.onClick.subscribe { profileButton.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java)); startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
@@ -59,7 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code") integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true);
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
@@ -70,7 +70,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) { 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; return@setOnClickListener;
} }
@@ -85,7 +85,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL"); UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return; return;
} }
@@ -101,7 +101,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) { if (existingProcessSecret != null) {
UIDialogs.toast(this, "This profile is already imported"); UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return; return;
} }
@@ -124,15 +124,11 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
finish(); finish();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e); 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 { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity";
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CommentDialog
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -72,7 +73,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { 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 { _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; val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) { if (processHandle == null) {
UIDialogs.toast(this, "No process handle set"); UIDialogs.toast(this, getString(R.string.no_process_handle_set));
return@showConfirmationDialog; return@showConfirmationDialog;
} }
@@ -122,13 +123,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false; var hasChanges = false;
val username = _editName.text.toString(); val username = _editName.text.toString();
if (username.length < 3) { 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; return@launch;
} }
val processHandle = StatePolycentric.instance.processHandle; val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) { if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, "Process handle unset"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
return@launch; return@launch;
} }
@@ -143,7 +144,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri); val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri);
if (bytes == null) { if (bytes == null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_read_image));
} }
return@launch; return@launch;
@@ -186,14 +187,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
if (hasChanges) { if (hasChanges) {
try { try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers(); processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
withContext(Dispatchers.Main) { 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) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to synchronize changes", e); Logger.w(TAG, "Failed to synchronize changes", e);
withContext(Dispatchers.Main) { 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) { } else if (resultCode == ImagePicker.RESULT_ERROR) {
UIDialogs.toast(this, ImagePicker.getError(data)); UIDialogs.toast(this, ImagePicker.getError(data));
} else { } 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.developerMode = true;
SettingsDev.instance.save(); SettingsDev.instance.save();
updateDevMode(); 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 androidx.collection.LruCache
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.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.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -49,6 +50,7 @@ class CachedPlatformClient : IPlatformClient {
return result; return result;
} }
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url); override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url);
override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.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.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -100,6 +101,8 @@ interface IPlatformClient {
*/ */
fun getContentDetails(url: String): IPlatformContentDetails; fun getContentDetails(url: String): IPlatformContentDetails;
fun getContentChapters(url: String): List<IChapter>;
/** /**
* Gets the playback tracker for a piece of content * Gets the playback tracker for a piece of content
*/ */
@@ -15,7 +15,8 @@ data class PlatformClientCapabilities(
val hasGetSearchCapabilities: Boolean = false, val hasGetSearchCapabilities: Boolean = false,
val hasGetChannelCapabilities: Boolean = false, val hasGetChannelCapabilities: Boolean = false,
val hasGetLiveEvents: 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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.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.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -181,6 +182,7 @@ open class JSClient : IPlatformClient {
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false, hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false, hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false, hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
); );
try { try {
@@ -414,6 +416,17 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})")); 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 @JSOptional
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url") @JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
@JSDocsParameter("url", "A content url (this platform)") @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 { private fun <T> isBusyWith(handle: ()->T): T {
try { try {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
@@ -66,15 +67,15 @@ class SourcePluginDescriptor {
@Serializable @Serializable
class AppPluginSettings { 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(); var tabEnabled = TabEnabled();
@Serializable @Serializable
class TabEnabled { 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; 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; 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; return toReturn;
} }
private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean { 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 { private fun calculateHash(item: IPlatformContent): Int {
return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode())); return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode()));
@@ -8,6 +8,7 @@ import java.util.stream.IntStream
*/ */
class MultiChronoContentPager : MultiPager<IPlatformContent> { class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {} 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 @Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int { 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() }; override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) { 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) { 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") Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
_pagersReusable.add(pagerToAdd.asReusable()); _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) { if (_currentResultPos >= _requestedPageItems.size) {
val startPos = fillDeferredUntil(_currentResultPos); val startPos = fillDeferredUntil(_currentResultPos);
if(!_pager.hasMorePages()) { if(!_pager.hasMorePages()) {
Logger.i("SingleAsyncItemPager", "end of async page reached");
completeRemainder { it?.complete(null) }; completeRemainder { it?.complete(null) };
} }
if(_isRequesting) if(_isRequesting)
@@ -4,13 +4,21 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.media.MediaSession2Service.MediaNotification
import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.concurrent.futures.ResolvableFuture import androidx.concurrent.futures.ResolvableFuture
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
@@ -27,10 +35,10 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.OffsetDateTime import java.time.OffsetDateTime
class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) : class BackgroundWorker(private val appContext: Context, private val workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) { CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if(StateApp.instance.isMainActive) { if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
Logger.i("BackgroundWorker", "CANCELLED"); Logger.i("BackgroundWorker", "CANCELLED");
return Result.success(); return Result.success();
} }
@@ -83,8 +91,11 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
val newSubChanges = hashSetOf<Subscription>(); val newSubChanges = hashSetOf<Subscription>();
val newItems = mutableListOf<IPlatformContent>(); val newItems = mutableListOf<IPlatformContent>();
val now = OffsetDateTime.now();
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}"); Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
synchronized(manager) { synchronized(manager) {
@@ -97,21 +108,76 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
} }
}, { sub, content -> }, { sub, content ->
synchronized(newSubChanges) { synchronized(newSubChanges) {
if(!newSubChanges.contains(sub)) if(!newSubChanges.contains(sub)) {
newSubChanges.add(sub); newSubChanges.add(sub);
if(sub.doNotifications && content.datetime?.let { it < now } == true)
contentNotifs.add(Pair(sub, content));
}
newItems.add(content); newItems.add(content);
} }
}); });
//Only for testing notifications
val testNotifs = 0;
if(contentNotifs.size == 0 && testNotifs > 0) {
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
.take(testNotifs).forEach {
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
}
}
} }
manager.cancel(12); manager.cancel(12);
if(newItems.size > 0) if(contentNotifs.size > 0) {
try {
val items = contentNotifs.take(5).toList()
for(i in items.indices) {
val contentNotif = items.get(i);
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
else null;
if(thumbnail != null)
Glide.with(appContext).asBitmap()
.load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
}
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onLoadFailed(errorDrawable: Drawable?) {
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
}
})
else
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
}
}
catch(ex: Throwable) {
Logger.e("BackgroundWorker", "Failed to create notif", ex);
}
}
/*
manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id) manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("Grayjay") .setContentTitle("Grayjay")
.setContentText("${newItems.size} new content from ${newSubChanges.size} creators") .setContentText("${newItems.size} new content from ${newSubChanges.size} creators")
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id).build()); .setChannelId(notificationChannel.id).build());*/
}
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("New by [${sub.channel.name}]")
.setContentText("${content.name}")
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setChannelId(notificationChannel.id);
if(thumbnail != null) {
//notifBuilder.setLargeIcon(thumbnail);
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
}
manager.notify(id, notifBuilder.build());
} }
} }
@@ -12,19 +12,44 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.toSafeFileName import com.futo.platformplayer.toSafeFileName
import com.futo.polycentric.core.toUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
class ChannelContentCache { class ChannelContentCache {
private val _targetCacheSize = 3000;
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
val _channelContents = HashMap(_channelCacheDir.listFiles() val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
.filter { it.isDirectory } init {
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer()) val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
.withoutBackup() val initializeTime = measureTimeMillis {
.load()) }); _channelContents = HashMap(allFiles
.filter { it.isDirectory }
.parallelStream().map {
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
.withoutBackup()
.load())
}.toList().associate { it })
}
val minDays = OffsetDateTime.now().minusDays(10);
val totalItems = _channelContents.map { it.value.count() }.sum();
val toTrim = totalItems - _targetCacheSize;
val trimmed: Int;
if(toTrim > 0) {
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
.sortedBy { it.datetime!! }.take(toTrim);
for(content in redundantContent)
uncacheContent(content);
trimmed = redundantContent.size;
}
else trimmed = 0;
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
}
fun getChannelCachePager(channelUrl: String): PlatformContentPager { fun getChannelCachePager(channelUrl: String): PlatformContentPager {
val validID = channelUrl.toSafeFileName(); val validID = channelUrl.toSafeFileName();
@@ -38,7 +63,9 @@ class ChannelContentCache {
return PlatformContentPager(items, Math.min(150, items.size)); return PlatformContentPager(items, Math.min(150, items.size));
} }
fun getSubscriptionCachePager(): DedupContentPager { fun getSubscriptionCachePager(): DedupContentPager {
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
val allUrls = subs.map { val allUrls = subs.map {
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
if(!otherUrls.contains(it.channel.url)) if(!otherUrls.contains(it.channel.url))
@@ -46,6 +73,7 @@ class ChannelContentCache {
else else
return@map otherUrls; return@map otherUrls;
}.flatten().distinct(); }.flatten().distinct();
Logger.i(TAG, "Subscriptions CachePager compiling");
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet(); val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
val validStores = _channelContents val validStores = _channelContents
@@ -58,7 +86,11 @@ class ChannelContentCache {
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
} }
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> { fun uncacheContent(content: SerializedPlatformContent) {
val store = getContentStore(content);
store?.delete(content);
}
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { cacheContent(it) }; return contents.filter { cacheContent(it) };
} }
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
@@ -66,14 +98,14 @@ class ChannelContentCache {
return false; return false;
val channelId = content.author.url.toSafeFileName(); val channelId = content.author.url.toSafeFileName();
val store = synchronized(_channelContents) { val store = getContentStore(channelId).let {
var channelStore = _channelContents.get(channelId); if(it == null) {
if(channelStore == null) { Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}"); val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load(); _channelContents.put(channelId, store);
_channelContents.put(channelId, channelStore); return@let store;
} }
return@synchronized channelStore; else return@let it;
} }
val serialized = SerializedPlatformContent.fromContent(content); val serialized = SerializedPlatformContent.fromContent(content);
val existing = store.findItems { it.url == content.url }; val existing = store.findItems { it.url == content.url };
@@ -88,6 +120,17 @@ class ChannelContentCache {
return existing.isEmpty(); return existing.isEmpty();
} }
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
val channelId = content.author.url.toSafeFileName();
return getContentStore(channelId);
}
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
return synchronized(_channelContents) {
var channelStore = _channelContents.get(channelId);
return@synchronized channelStore;
}
}
companion object { companion object {
private val TAG = "ChannelCache"; private val TAG = "ChannelCache";
@@ -95,10 +138,11 @@ class ChannelContentCache {
private var _instance: ChannelContentCache? = null; private var _instance: ChannelContentCache? = null;
val instance: ChannelContentCache get() { val instance: ChannelContentCache get() {
synchronized(_lock) { synchronized(_lock) {
if(_instance == null) if(_instance == null) {
_instance = ChannelContentCache(); _instance = ChannelContentCache();
return _instance!!; }
} }
return _instance!!;
} }
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> { fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
@@ -114,7 +158,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]"); Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -134,7 +178,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription results"); Logger.i(TAG, "Caching ${results.size} subscription results");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -352,16 +353,25 @@ class StateCasting {
} }
} }
} else { } else {
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); 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()); 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); castLocalVideo(video, videoSource, resumePosition);
} else if (audioSource is LocalAudioSource) { else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition); castLocalAudio(video, audioSource, resumePosition);
} else { else {
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource"); 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.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager 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) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers() processHandle.fullyBackfillServers()
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e); 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; _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; _textMetadata?.text = metadata;
_lastChannel = channel; _lastChannel = channel;
setLinks(channel.links, channel.name); setLinks(channel.links, channel.name);
@@ -91,7 +91,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
l.removeAllViews(); l.removeAllViews();
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
_textFindOn?.text = "Find $name on"; _textFindOn?.text = getString(R.string.find_name_on).replace("{name}", name);
_textFindOn?.visibility = View.VISIBLE; _textFindOn?.visibility = View.VISIBLE;
for (pair in links) { for (pair in links) {
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.IRefreshPager import com.futo.platformplayer.api.media.structures.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.IReplacerPager
import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -33,6 +34,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -74,9 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, { private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
return@TaskHandler getContentPager(it); return@TaskHandler getContentPager(it);
}).success { }).success { livePager ->
setLoading(false); setLoading(false);
setPager(it);
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
setPager(pager);
} }
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
@@ -248,7 +255,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager is IReplacerPager<*>) if(_pager is IReplacerPager<*>)
(_pager as IReplacerPager<*>).onReplaced.remove(this); (_pager as IReplacerPager<*>).onReplaced.remove(this);
if(pager is IReplacerPager<*>) { if(pager is IReplacerPager<*>) {
pager.onReplaced.subscribe(this) { oldItem, newItem -> pager.onReplaced.subscribe(this) { oldItem, newItem ->
if(_pager != pager) if(_pager != pager)
@@ -257,11 +263,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager !is IPager<IPlatformContent>) if(_pager !is IPager<IPlatformContent>)
return@subscribe; return@subscribe;
val toReplaceIndex = _results.indexOfFirst { it == newItem };
if(toReplaceIndex >= 0) { lifecycleScope.launch(Dispatchers.Main) {
_results[toReplaceIndex] = newItem as IPlatformContent; val toReplaceIndex = _results.indexOfFirst { it == oldItem };
_adapterResults?.let { if (toReplaceIndex >= 0) {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex)); _results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
}
} }
} }
} }
@@ -56,7 +56,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
}.exception<ScriptCaptchaRequiredException> { } }.exception<ScriptCaptchaRequiredException> { }
.exceptionWithParameter<Throwable> { ex, para -> .exceptionWithParameter<Throwable> { ex, para ->
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); 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(); loadNext();
}; };
@@ -220,6 +220,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttons.removeAt(buyIndex) buttons.removeAt(buyIndex)
buttons.add(0, button) 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) { for (data in buttons) {
val button = MenuButton(context, data, _fragment, true); val button = MenuButton(context, data, _fragment, true);
@@ -289,6 +296,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (!StatePayment.instance.hasPaid) { 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(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 //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 -> _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception ->
if(success) { 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)); UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true); _fragment.close(true);
} }
else { 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() { private fun paid() {
val licenseInput = SlideUpMenuTextInput(context, "License"); val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), "Enter license key", "Ok", true, licenseInput); 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 { productLicenseDialog.onOK.subscribe {
val licenseText = licenseInput.text; val licenseText = licenseInput.text;
if (licenseText.isNullOrEmpty()) { 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; return@subscribe;
} }
@@ -127,19 +127,19 @@ class BuyFragment : MainFragment() {
licenseInput.clear(); licenseInput.clear();
productLicenseDialog.hide(true); 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); _fragment.close(true);
} }
else 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) { catch(ex: Throwable) {
Logger.e("BuyFragment", "Failed to activate key", ex); Logger.e("BuyFragment", "Failed to activate key", ex);
withContext(Dispatchers.Main) { 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.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView 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.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
@@ -100,6 +102,7 @@ class ChannelFragment : MainFragment() {
private var _viewPager: ViewPager2; private var _viewPager: ViewPager2;
private var _tabLayoutMediator: TabLayoutMediator; private var _tabLayoutMediator: TabLayoutMediator;
private var _buttonSubscribe: SubscribeButton; private var _buttonSubscribe: SubscribeButton;
private var _buttonSubscriptionSettings: ImageButton;
private var _overlayContainer: FrameLayout; private var _overlayContainer: FrameLayout;
private var _overlay_loading: LinearLayout; private var _overlay_loading: LinearLayout;
@@ -141,7 +144,7 @@ class ChannelFragment : MainFragment() {
UIDialogs.showDialog(context, UIDialogs.showDialog(context,
R.drawable.ic_sources, 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, 0,
UIDialogs.Action("Back", { UIDialogs.Action("Back", {
fragment.close(true); fragment.close(true);
@@ -160,10 +163,25 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail = findViewById(R.id.creator_thumbnail); _creatorThumbnail = findViewById(R.id.creator_thumbnail);
_imageBanner = findViewById(R.id.image_channel_banner); _imageBanner = findViewById(R.id.image_channel_banner);
_buttonSubscribe = findViewById(R.id.button_subscribe); _buttonSubscribe = findViewById(R.id.button_subscribe);
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
_overlay_loading = findViewById(R.id.channel_loading_overlay); _overlay_loading = findViewById(R.id.channel_loading_overlay);
_overlay_loading_spinner = findViewById(R.id.channel_loader); _overlay_loading_spinner = findViewById(R.id.channel_loader);
_overlayContainer = findViewById(R.id.overlay_container); _overlayContainer = findViewById(R.id.overlay_container);
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
}
_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) //TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false; viewPager.isSaveEnabled = false;
viewPager.registerOnPageChangeCallback(_onPageChangeCallback); viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
@@ -246,28 +264,46 @@ class ChannelFragment : MainFragment() {
if (parameter is String) { if (parameter is String) {
_buttonSubscribe.setSubscribeChannel(parameter); _buttonSubscribe.setSubscribeChannel(parameter);
_textChannel.text = ""; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannelSub.text = ""; setPolycentricProfileOr(parameter) {
_textChannel.text = "";
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(null, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
};
_url = parameter; _url = parameter;
loadChannel(); loadChannel();
} else if (parameter is SerializedChannel) { } else if (parameter is SerializedChannel) {
showChannel(parameter); showChannel(parameter);
_url = parameter.url; _url = parameter.url;
_creatorThumbnail.setThumbnail(parameter.url, false);
loadChannel(); loadChannel();
} else if (parameter is IPlatformChannel) } else if (parameter is IPlatformChannel)
showChannel(parameter); showChannel(parameter);
else if (parameter is PlatformAuthorLink) { else if (parameter is PlatformAuthorLink) {
_textChannel.text = parameter.name; setPolycentricProfileOr(parameter.url) {
_textChannelSub.text = ""; _textChannel.text = parameter.name;
_creatorThumbnail.setThumbnail(parameter.url, false); _textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.id);
};
_url = parameter.url; _url = parameter.url;
loadChannel(); loadChannel();
} else if (parameter is Subscription) { } else if (parameter is Subscription) {
_textChannel.text = parameter.channel.name; setPolycentricProfileOr(parameter.channel.url) {
_textChannelSub.text = ""; _textChannel.text = parameter.channel.name;
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false); _textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.channel.id);
};
_url = parameter.channel.url; _url = parameter.channel.url;
loadChannel(); loadChannel();
@@ -327,19 +363,19 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.onShown(channel); _fragment.topBar?.onShown(channel);
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { 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) { UIDialogs.showDialogProgress(context) {
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
StatePlaylists.instance.createPlaylistFromChannel(channel) { page -> StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
_fragment.lifecycleScope.launch(Dispatchers.Main) { _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) { catch(ex: Exception) {
Logger.e(TAG, "Error", ex); 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) { withContext(Dispatchers.Main) {
@@ -360,14 +396,8 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons); _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else ""; _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner)
//TODO: Find a better way to access the adapter fragments.. //TODO: Find a better way to access the adapter fragments..
@@ -381,51 +411,68 @@ class ChannelFragment : MainFragment() {
this.channel = channel; 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) { if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false); setPolycentricProfile(cachedProfile, animate = false);
} else { } else {
setPolycentricProfile(null, animate = false); setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(channel.id); or();
} }
} }
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)") Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
val polycentricProfile = cachedPolycentricProfile?.profile; val dp_35 = 35.dp(resources)
if (polycentricProfile != null) { val profile = cachedPolycentricProfile?.profile;
_fragment.topBar?.onShown(polycentricProfile); 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()) if (avatar != null) {
_textChannel.text = polycentricProfile.systemState.username; _creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
val dp_35 = 35.dp(resources) val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35) ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
if (avatar != null) { if (banner != null) {
_creatorThumbnail.setThumbnail(avatar, true); Glide.with(_imageBanner)
} else { .load(banner)
_creatorThumbnail.setHarborAvailable(true, true); .crossfade()
} .into(_imageBanner);
} else {
Glide.with(_imageBanner)
.load(channel?.banner)
.crossfade()
.into(_imageBanner);
}
val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage() if (profile != null) {
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) }; _fragment.topBar?.onShown(profile);
_textChannel.text = profile.systemState.username;
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
}
} }
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let { (_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelListFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile, animate);
//TODO: Call on other tabs as needed //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.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter 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.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlin.math.floor import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment { 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 //TODO: Reconstruct search video from detail if search is null
_overlayContainer.let { _overlayContainer.let {
if(content is IPlatformVideo) if(content is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(content, it) { UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
if (fragment is HomeFragment) { { StateMeta.instance.addHiddenVideo(content.url);
val removeIndex = recyclerData.results.indexOf(content); if (fragment is HomeFragment) {
if (removeIndex >= 0) { val removeIndex = recyclerData.results.indexOf(content);
recyclerData.results.removeAt(removeIndex); if (removeIndex >= 0) {
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex)); 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) { adapter.onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it); StatePlayer.instance.addToQueue(it);
val name = if (it.name.length > 20) (it.name.subSequence(0, 20).toString() + "...") else it.name; 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);
} }
}; };
} }
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -89,7 +90,7 @@ class ContentSearchResultsFragment : MainFragment() {
}) })
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .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() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
} }
} }
@@ -101,14 +102,12 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
if(!isBack) { setQuery(parameter.query, false);
setQuery(parameter.query, false); setChannelUrl(parameter.channelUrl, false);
setChannelUrl(parameter.channelUrl, false);
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
this.setText(parameter.query); this.setText(parameter.query);
}
} }
} }
} }
@@ -145,7 +144,10 @@ class ContentSearchResultsFragment : MainFragment() {
}; };
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it, true); if(it.isHttpUrl())
navigate<VideoDetailFragment>(it);
else
setQuery(it, true);
}; };
} }
} }
@@ -71,16 +71,14 @@ class CreatorSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) { if(parameter is String) {
if(!isBack) { setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
setText(parameter); setText(parameter);
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it); setQuery(it);
}; };
}
} }
} }
} }
@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.Spinner import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.views.adapters.SubscriptionAdapter import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() { class CreatorsFragment : MainFragment() {
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _spinnerSortBy: Spinner? = null; private var _spinnerSortBy: Spinner? = null;
private var _overlayContainer: FrameLayout? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
_overlayContainer = view.findViewById(R.id.overlay_container);
val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby); val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_spinnerSortBy = null; _spinnerSortBy = null;
_overlayContainer = null;
} }
companion object { companion object {
@@ -136,8 +136,8 @@ class DownloadsFragment : MainFragment() {
fun reloadUI() { fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true); val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} Used"; _usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
_usageAvailable.text = "${usage.available.toHumanBytesSize()} Available"; _usageAvailable.text = "${usage.available.toHumanBytesSize()} " + context.getString(R.string.available);
_usageProgress.progress = usage.percentage.toFloat(); _usageProgress.progress = usage.percentage.toFloat();
@@ -161,7 +161,7 @@ class DownloadsFragment : MainFragment() {
_listPlaylistsContainer.visibility = GONE; _listPlaylistsContainer.visibility = GONE;
else { else {
_listPlaylistsContainer.visibility = VISIBLE; _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(); _listPlaylists.removeAllViews();
for(view in playlists.map { PlaylistDownloadItem(context, it) }) { for(view in playlists.map { PlaylistDownloadItem(context, it) }) {
@@ -176,7 +176,7 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE; _listDownloadedHeader.visibility = GONE;
} else { } else {
_listDownloadedHeader.visibility = VISIBLE; _listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} videos)"; _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
} }
_listDownloaded.setData(downloaded); _listDownloaded.setData(downloaded);
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
@@ -141,10 +142,18 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it); val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults); recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it); recyclerData.resultsUnfiltered.addAll(it);
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size); if(filteredResults.isEmpty()) {
filteredNextPageCounter++
if(filteredNextPageCounter <= 4)
loadNextPage()
}
else {
filteredNextPageCounter = 0;
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); 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(); loadNextPage();
}); });
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() }); //UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
@@ -256,7 +265,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
if(jsVideoPager != null) 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 else
UIDialogs.toast(it, kv.value.message ?: "", false); UIDialogs.toast(it, kv.value.message ?: "", false);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -333,11 +342,11 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
parentPager.onPagerError.subscribe(this) { parentPager.onPagerError.subscribe(this) {
Logger.e(TAG, "Search pager failed: ${it.message}", it); Logger.e(TAG, "Search pager failed: ${it.message}", it);
when (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 -> { is CancellationException -> {
//Hide cancelled toast //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<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> { .exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); 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.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("Ignore", {}), UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY) UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
.exception<ScriptImplementationException> { .exception<ScriptImplementationException> {
Logger.w(TAG, "Plugin failure.", it); 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.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("Ignore", {}), UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY) UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load channel.", it); 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() loadResults()
}) { }) {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
@@ -159,7 +159,7 @@ class HomeFragment : MainFragment() {
} }
private fun loadedResult(pager : IPager<IPlatformContent>) { private fun loadedResult(pager : IPager<IPlatformContent>) {
if (pager is EmptyPager<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}"); Logger.i(TAG, "Got new home pager ${pager}");
@@ -113,7 +113,7 @@ class ImportPlaylistsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para -> }.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false); //setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); 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(); }); //UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext(); loadNext();
}; };
@@ -144,14 +144,14 @@ class ImportPlaylistsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?; val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let { tb?.let {
it.title = "Import Playlists"; it.title = context.getString(R.string.import_playlists);
it.onImport.subscribe(this) { it.onImport.subscribe(this) {
val playlistsToImport = _items.filter { i -> i.selected }.toList(); val playlistsToImport = _items.filter { i -> i.selected }.toList();
for (playlistToImport in playlistsToImport) { for (playlistToImport in playlistsToImport) {
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist);
} }
UIDialogs.toast("${playlistsToImport.size} playlists imported."); UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
_fragment.closeSegment(); _fragment.closeSegment();
}; };
} }
@@ -175,7 +175,7 @@ class ImportPlaylistsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected }; val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) { if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all); _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); (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else { } else {
_textSelectDeselectAll.text = context.getString(R.string.select_all); _textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -116,7 +116,7 @@ class ImportSubscriptionsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para -> }.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false); //setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); 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(); }); //UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext(); loadNext();
}; };
@@ -147,14 +147,14 @@ class ImportSubscriptionsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?; val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let { tb?.let {
it.title = "Import Subscriptions"; it.title = context.getString(R.string.import_subscriptions);
it.onImport.subscribe(this) { it.onImport.subscribe(this) {
val subscriptionsToImport = _items.filter { i -> i.selected }.toList(); val subscriptionsToImport = _items.filter { i -> i.selected }.toList();
for (subscriptionToImport in subscriptionsToImport) { for (subscriptionToImport in subscriptionsToImport) {
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel); StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
} }
UIDialogs.toast("${subscriptionsToImport.size} subscriptions imported."); UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
_fragment.closeSegment(); _fragment.closeSegment();
}; };
} }
@@ -165,7 +165,7 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) { if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) { if (!_limitToastShown) {
_limitToastShown = true; _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); setLoading(false);
@@ -187,7 +187,7 @@ class ImportSubscriptionsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected }; val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) { if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all); _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); (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else { } else {
_textSelectDeselectAll.text = context.getString(R.string.select_all); _textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -210,7 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75; private const val MAXIMUM_BATCH_SIZE = 90;
fun newInstance() = ImportSubscriptionsFragment().apply {} fun newInstance() = ImportSubscriptionsFragment().apply {}
} }
} }
@@ -80,8 +80,8 @@ class PlaylistFragment : MainFragment() {
constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) { constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) {
_fragment = fragment; _fragment = fragment;
val nameInput = SlideUpMenuTextInput(context, "Name"); val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, "Edit playlist", "Ok", false, nameInput); val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
_buttonDownload.visibility = View.VISIBLE; _buttonDownload.visibility = View.VISIBLE;
editPlaylistOverlay.onOK.subscribe { editPlaylistOverlay.onOK.subscribe {
@@ -113,14 +113,14 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return@setOnShare; val playlist = _playlist ?: return@setOnShare;
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist); val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
UISlideOverlays.showOverlay(overlayContainer, "Playlist [${playlist.name}]", null, {}, UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
SlideUpMenuItem(context, R.drawable.ic_list, "Share as Text", "Share as a list of video urls", 1, { 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) _fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("text/plain") .setType("text/plain")
.setText(reconstruction) .setText(reconstruction)
.intent); .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); val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
_fragment.startActivity(ShareCompat.IntentBuilder(context) _fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("application/json") .setType("application/json")
@@ -146,7 +146,7 @@ class PlaylistFragment : MainFragment() {
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load playlist.", it); Logger.w(TAG, "Failed to load playlist.", it);
val c = context ?: return@exception; 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) { _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
val remotePlaylist = _remotePlaylist; val remotePlaylist = _remotePlaylist;
if (remotePlaylist == null) { 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; return@Pair;
} }
@@ -245,7 +245,7 @@ class PlaylistFragment : MainFragment() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setLoading(false); setLoading(false);
UIDialogs.toast("Playlist copied as local playlist"); UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist));
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -284,7 +284,7 @@ class PlaylistFragment : MainFragment() {
_buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() }; _buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener { _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); StateDownloads.instance.deleteCachedPlaylist(playlist.id);
}); });
} }
@@ -292,7 +292,7 @@ class PlaylistFragment : MainFragment() {
else if(isDownloaded) { else if(isDownloaded) {
_buttonDownload.setImageResource(R.drawable.ic_download_off); _buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener { _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); StateDownloads.instance.deleteCachedPlaylist(playlist.id);
}); });
} }
@@ -73,16 +73,14 @@ class PlaylistSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) { if(parameter is String) {
if(!isBack) { setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
setText(parameter); setText(parameter);
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it); setQuery(it);
}; };
}
} }
} }
} }
@@ -92,8 +92,8 @@ class PlaylistsFragment : MainFragment() {
recyclerPlaylists.adapter = _adapterPlaylist; recyclerPlaylists.adapter = _adapterPlaylist;
recyclerPlaylists.layoutManager = LinearLayoutManager(context); recyclerPlaylists.layoutManager = LinearLayoutManager(context);
val nameInput = SlideUpMenuTextInput(context, "Name"); val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), "Create new playlist", "Ok", false, nameInput); 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.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
_adapterPlaylist.onPlay.subscribe { p -> _adapterPlaylist.onPlay.subscribe { p ->
@@ -130,7 +130,7 @@ class PlaylistsFragment : MainFragment() {
_appBar = findViewById(R.id.app_bar); _appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist); _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) { StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
updateWatchLater(); updateWatchLater();
}; };
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -154,13 +155,13 @@ class PostDetailFragment : MainFragment {
{ {
val result = StatePlatform.instance.getContentDetails(it).await(); val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformPostDetails) 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; return@TaskHandler result;
}) })
.success { setPostDetails(it) } .success { setPostDetails(it) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load post.", it); Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load post", it, ::fetchPost); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) 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); _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); val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop); root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop); _commentsList.setPrependedView(layoutTop);
@@ -222,7 +228,7 @@ class PostDetailFragment : MainFragment {
val replyCount = c.replyCount ?: 0; val replyCount = c.replyCount ?: 0;
var metadata = ""; var metadata = "";
if (replyCount > 0) { if (replyCount > 0) {
metadata += "$replyCount replies"; metadata += "$replyCount " + context.getString(R.string.replies);
} }
if (c is PolycentricPlatformComment) { if (c is PolycentricPlatformComment) {
@@ -357,7 +363,9 @@ class PostDetailFragment : MainFragment {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
@@ -601,7 +609,7 @@ class PostDetailFragment : MainFragment {
val subscribers = value?.author?.subscribers; val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) { if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE; _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 { } else {
_channelMeta.visibility = View.GONE; _channelMeta.visibility = View.GONE;
_channelMeta.text = ""; _channelMeta.text = "";
@@ -63,6 +63,7 @@ class SourceDetailFragment : MainFragment() {
private val _sourceHeader: SourceHeaderView; private val _sourceHeader: SourceHeaderView;
private val _sourceButtons: LinearLayout; private val _sourceButtons: LinearLayout;
private val _sourceAdvancedButtons: LinearLayout;
private val _layoutLoader: FrameLayout; private val _layoutLoader: FrameLayout;
private val _imageSpinner: ImageView; private val _imageSpinner: ImageView;
@@ -82,6 +83,7 @@ class SourceDetailFragment : MainFragment() {
this.fragment = fragment; this.fragment = fragment;
_sourceHeader = findViewById(R.id.source_header); _sourceHeader = findViewById(R.id.source_header);
_sourceButtons = findViewById(R.id.source_buttons); _sourceButtons = findViewById(R.id.source_buttons);
_sourceAdvancedButtons = findViewById(R.id.advanced_source_buttons);
_settingsAppForm = findViewById(R.id.source_app_setings); _settingsAppForm = findViewById(R.id.source_app_setings);
_settingsForm = findViewById(R.id.source_settings); _settingsForm = findViewById(R.id.source_settings);
_layoutLoader = findViewById(R.id.layout_loader); _layoutLoader = findViewById(R.id.layout_loader);
@@ -107,7 +109,7 @@ class SourceDetailFragment : MainFragment() {
StatePlugins.instance.setPluginSettings(id, _settings!!); StatePlugins.instance.setPluginSettings(id, _settings!!);
reloadSource(id); reloadSource(id);
UIDialogs.toast("Plugin settings saved", false); UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false);
} }
if(_settingsAppChanged) { if(_settingsAppChanged) {
_settingsAppForm.setObjectValues(); _settingsAppForm.setObjectValues();
@@ -144,8 +146,8 @@ class SourceDetailFragment : MainFragment() {
try { try {
_settings = settingValues; _settings = settingValues;
_settingsForm.fromPluginSettings( _settingsForm.fromPluginSettings(
settings, settingValues, "Plugin settings", settings, settingValues, context.getString(R.string.plugin_settings),
"These settings are defined by the plugin" context.getString(R.string.these_settings_are_defined_by_the_plugin)
); );
_settingsForm.onChanged.clear(); _settingsForm.onChanged.clear();
_settingsForm.onChanged.subscribe { field, value -> _settingsForm.onChanged.subscribe { field, value ->
@@ -158,7 +160,7 @@ class SourceDetailFragment : MainFragment() {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to load source", ex); 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); val isEnabled = StatePlatform.instance.isClientEnabled(source);
groups.add( groups.add(
BigButtonGroup(c, "Update", BigButtonGroup(c, context.getString(R.string.update),
BigButton(c, "Check for updates", "Checks for new versions of the source", R.drawable.ic_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(); checkForUpdatesSource();
} }
) )
@@ -213,9 +215,16 @@ class SourceDetailFragment : MainFragment() {
if (source.isLoggedIn) { if (source.isLoggedIn) {
groups.add( groups.add(
BigButtonGroup(c, "Authentication", BigButtonGroup(c, context.getString(R.string.authentication),
BigButton(c, "Logout", "Sign out of the platform", R.drawable.ic_logout) { BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource(); logoutSource();
},
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false);
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} }
) )
); );
@@ -223,7 +232,7 @@ class SourceDetailFragment : MainFragment() {
val migrationButtons = mutableListOf<BigButton>(); val migrationButtons = mutableListOf<BigButton>();
if (isEnabled && source.capabilities.hasGetUserSubscriptions) { if (isEnabled && source.capabilities.hasGetUserSubscriptions) {
migrationButtons.add( 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."); Logger.i(TAG, "Import subscriptions clicked.");
importSubscriptionsSource(); importSubscriptionsSource();
} }
@@ -231,7 +240,7 @@ class SourceDetailFragment : MainFragment() {
} }
if (isEnabled && source.capabilities.hasGetUserPlaylists && source.capabilities.hasGetPlaylist) { 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."); Logger.i(TAG, "Import playlists clicked.");
importPlaylistsSource(); importPlaylistsSource();
}; };
@@ -244,13 +253,13 @@ class SourceDetailFragment : MainFragment() {
} }
if (migrationButtons.size > 0) { if (migrationButtons.size > 0) {
groups.add(BigButtonGroup(c, "Migration", *migrationButtons.toTypedArray())); groups.add(BigButtonGroup(c, context.getString(R.string.migration), *migrationButtons.toTypedArray()));
} }
} else { } else {
if(config.authentication != null) { if(config.authentication != null) {
groups.add( groups.add(
BigButtonGroup(c, "Authentication", BigButtonGroup(c, context.getString(R.string.authentication),
BigButton(c, "Login", "Sign into the platform of this source", R.drawable.ic_login) { BigButton(c, context.getString(R.string.login), context.getString(R.string.sign_into_the_platform_of_this_source), R.drawable.ic_login) {
loginSource(); loginSource();
} }
) )
@@ -260,8 +269,8 @@ class SourceDetailFragment : MainFragment() {
val clientIfExists = StatePlugins.instance.getPlugin(config.id); val clientIfExists = StatePlugins.instance.getPlugin(config.id);
groups.add( groups.add(
BigButtonGroup(c, "Management", BigButtonGroup(c, context.getString(R.string.management),
BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) { BigButton(c, context.getString(R.string.uninstall), context.getString(R.string.removes_the_plugin_from_the_app), R.drawable.ic_block) {
uninstallSource(); uninstallSource();
}.withBackground(R.drawable.background_big_button_red).apply { }.withBackground(R.drawable.background_big_button_red).apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
@@ -269,8 +278,8 @@ class SourceDetailFragment : MainFragment() {
}; };
}, },
if(clientIfExists?.captchaEncrypted != null) if(clientIfExists?.captchaEncrypted != null)
BigButton(c, "Delete Captcha", "Deletes stored captcha answer for this plugin", R.drawable.ic_block) { 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); clientIfExists.updateCaptcha(null);
}.apply { }.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).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); setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
@@ -283,6 +292,27 @@ class SourceDetailFragment : MainFragment() {
for (group in groups) { for (group in groups) {
_sourceButtons.addView(group); _sourceButtons.addView(group);
} }
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
}.apply {
this.alpha = 0.5f;
},
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
reloadSource(config.id);
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
}.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);
};
} else null
)
_sourceAdvancedButtons.removeAllViews();
_sourceAdvancedButtons.addView(advancedButtons);
} }
@@ -298,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
reloadSource(config.id); reloadSource(config.id);
}; };
} }
private fun logoutSource() { private fun logoutSource(clear: Boolean = true) {
val config = _config ?: return; val config = _config ?: return;
StatePlugins.instance.setPluginAuth(config.id, null); StatePlugins.instance.setPluginAuth(config.id, null);
@@ -306,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
//TODO: Maybe add a dialog option.. //TODO: Maybe add a dialog option..
if(Settings.instance.plugins.clearCookiesOnLogout) { if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@@ -329,7 +359,7 @@ class SourceDetailFragment : MainFragment() {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { 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 { } finally {
setLoading(false); setLoading(false);
@@ -354,14 +384,14 @@ class SourceDetailFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val subscriptions = source.getUserSubscriptions().distinct(); 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) { withContext(Dispatchers.Main) {
fragment.navigate<ImportSubscriptionsFragment>(subscriptions); fragment.navigate<ImportSubscriptionsFragment>(subscriptions);
} }
} catch(e: Throwable) { } catch(e: Throwable) {
withContext(Dispatchers.Main) { 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 { } finally {
setLoading(false); setLoading(false);
@@ -375,7 +405,7 @@ class SourceDetailFragment : MainFragment() {
val config = _config ?: return; val config = _config ?: return;
val source = StatePlatform.instance.getClient(config.id); 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); StatePlugins.instance.deletePlugin(source.id);
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
@@ -386,7 +416,7 @@ class SourceDetailFragment : MainFragment() {
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Uninstalled ${source.name}"); UIDialogs.toast(context, context.getString(R.string.uninstalled) + " ${source.name}");
fragment.closeSegment(); fragment.closeSegment();
} }
} }
@@ -405,7 +435,7 @@ class SourceDetailFragment : MainFragment() {
if (!response.isOk || response.body == null) { if (!response.isOk || response.body == null) {
Logger.w(TAG, "Failed to check for updates (sourceUrl=${sourceUrl}, response.isOk=${response.isOk}, response.body=${response.body})."); 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; return@launch;
} }
@@ -415,7 +445,7 @@ class SourceDetailFragment : MainFragment() {
val config = SourcePluginConfig.fromJson(configJson); val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) { if (config.version <= c.version) {
Logger.i(TAG, "Plugin is up to date."); 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; return@launch;
} }
@@ -430,7 +460,7 @@ class SourceDetailFragment : MainFragment() {
Logger.i(TAG, "Started add source activity."); Logger.i(TAG, "Started add source activity.");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e); 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)); };
} }
} }
} }
@@ -87,6 +87,7 @@ class SubscriptionsFeedFragment : MainFragment() {
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> { class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total -> StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
@@ -110,13 +111,18 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
fun onShown() { fun onShown() {
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress(); val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
setProgress(currentProgress.first, currentProgress.second); setProgress(currentProgress.first, currentProgress.second);
if(recyclerData.loadedFeedStyle != feedStyle || if(recyclerData.loadedFeedStyle != feedStyle ||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) { recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now(); recyclerData.lastLoad = OffsetDateTime.now();
loadResults();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
loadResults(false);
else if(recyclerData.results.size == 0)
loadCache();
} }
val announcementsView = _announcementsView; val announcementsView = _announcementsView;
@@ -176,6 +182,7 @@ class SubscriptionsFeedFragment : MainFragment() {
if(rateLimitPlugins.any()) if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id }); throw RateLimitException(rateLimitPlugins.map { it.key.id });
} }
_bypassRateLimit = false;
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
@@ -195,11 +202,10 @@ class SubscriptionsFeedFragment : MainFragment() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_security_pred, 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." + 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),
"\n\nYou have too many subscriptions for the following plugins:\n", subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
subsByLimited.map { "${it.first.config.name}: ${it.second.size} Subscriptions" } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
_bypassRateLimit = true; _bypassRateLimit = true;
loadResults(); loadResults(true);
}, UIDialogs.ActionStyle.DANGEROUS_TEXT), }, UIDialogs.ActionStyle.DANGEROUS_TEXT),
UIDialogs.Action("OK", { UIDialogs.Action("OK", {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
@@ -211,7 +217,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
if(it !is CancellationException) if(it !is CancellationException)
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
else { else {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
@@ -226,10 +232,10 @@ class SubscriptionsFeedFragment : MainFragment() {
synchronized(_filterLock) { synchronized(_filterLock) {
_subscriptionBar?.setToggles( _subscriptionBar?.setToggles(
SubscriptionBar.Toggle("Videos", _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, SubscriptionBar.Toggle(context.getString(R.string.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(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle("Live", _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, SubscriptionBar.Toggle(context.getString(R.string.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.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }
); );
} }
@@ -276,15 +282,19 @@ class SubscriptionsFeedFragment : MainFragment() {
loadResults(true); loadResults(true);
} }
private fun loadCache() {
Logger.i(TAG, "Subscriptions load cache");
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
val results = cachePager.getResults();
Logger.i(TAG, "Subscriptions show cache (${results.size})");
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setPager(cachePager);
}
private fun loadResults(withRefetch: Boolean = false) { private fun loadResults(withRefetch: Boolean = false) {
setLoading(true); setLoading(true);
Logger.i(TAG, "Subscriptions load"); Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) { if(recyclerData.results.size == 0) {
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); loadCache();
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);
setPager(cachePager);
} else { } else {
setTextCentered(null); setTextCentered(null);
} }
@@ -292,14 +302,14 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
private fun loadedResult(pager: IPager<IPlatformContent>) { private fun loadedResult(pager: IPager<IPlatformContent>) {
Logger.i(TAG, "Subscriptions new pager loaded"); Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
setPager(pager); 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) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to finish loading", e) Logger.e(TAG, "Failed to finish loading", e)
} }
@@ -326,7 +336,7 @@ class SubscriptionsFeedFragment : MainFragment() {
if (toShow is PluginException) if (toShow is PluginException)
UIDialogs.toast( UIDialogs.toast(
it, 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 else
UIDialogs.toast(it, ex.message ?: ""); UIDialogs.toast(it, ex.message ?: "");
@@ -340,7 +350,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.map { it!! } .map { it!! }
.toList(); .toList();
for(distinctPluginFail in failedPlugins) 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) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to handle exceptions", e) Logger.e(TAG, "Failed to handle exceptions", e)
@@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
} else if (_searchType == SearchType.PLAYLIST) { } else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(it); navigate<PlaylistSearchResultsFragment>(it);
} else { } else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); if(it.isHttpUrl())
navigate<VideoDetailFragment>(it);
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
} }
}; };
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri import android.net.Uri
import android.provider.Browser
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.text.Spanned import android.text.Spanned
import android.util.AttributeSet import android.util.AttributeSet
@@ -23,7 +22,6 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.widget.* import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -38,6 +36,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
@@ -62,8 +61,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
@@ -94,7 +95,6 @@ import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.ui.PlayerControlView import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.TimeBar import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
import com.google.common.base.Stopwatch
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.* import kotlinx.coroutines.*
import userpackage.Protocol import userpackage.Protocol
@@ -172,6 +172,8 @@ class VideoDetailView : ConstraintLayout {
private val _addCommentView: AddCommentView; private val _addCommentView: AddCommentView;
private val _toggleCommentType: Toggle; private val _toggleCommentType: Toggle;
private val _layoutSkip: LinearLayout;
private val _textSkip: TextView;
private val _textResume: TextView; private val _textResume: TextView;
private val _layoutResume: LinearLayout; private val _layoutResume: LinearLayout;
private var _jobHideResume: Job? = null; private var _jobHideResume: Job? = null;
@@ -295,6 +297,8 @@ class VideoDetailView : ConstraintLayout {
_addCommentView = findViewById(R.id.add_comment_view); _addCommentView = findViewById(R.id.add_comment_view);
_commentsList = findViewById(R.id.comments_list); _commentsList = findViewById(R.id.comments_list);
_layoutSkip = findViewById(R.id.layout_skip);
_textSkip = findViewById(R.id.text_skip);
_layoutResume = findViewById(R.id.layout_resume); _layoutResume = findViewById(R.id.layout_resume);
_textResume = findViewById(R.id.text_resume); _textResume = findViewById(R.id.text_resume);
_layoutPlayerContainer = findViewById(R.id.layout_player_container); _layoutPlayerContainer = findViewById(R.id.layout_player_container);
@@ -313,6 +317,11 @@ class VideoDetailView : ConstraintLayout {
_layoutMonetization.visibility = View.GONE; _layoutMonetization.visibility = View.GONE;
_player.attachPlayer(); _player.attachPlayer();
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
_container_content_liveChat.onRaidNow.subscribe { _container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue(); StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl); fragment.navigate<VideoDetailFragment>(it.targetUrl);
@@ -397,6 +406,21 @@ class VideoDetailView : ConstraintLayout {
_cast.onSettingsClick.subscribe { showVideoSettings() }; _cast.onSettingsClick.subscribe { showVideoSettings() };
_player.onVideoSettings.subscribe { showVideoSettings() }; _player.onVideoSettings.subscribe { showVideoSettings() };
_player.onToggleFullScreen.subscribe(::handleFullScreen); _player.onToggleFullScreen.subscribe(::handleFullScreen);
_player.onChapterChanged.subscribe { chapter, isScrub ->
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
_layoutSkip.visibility = GONE;
if(!isScrub) {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
}
else if(chapter?.type == ChapterType.SKIP) {
_player.seekTo(chapter.timeEnd.toLong() * 1000);
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
}
}
}
_cast.onMinimizeClick.subscribe { _cast.onMinimizeClick.subscribe {
_player.setFullScreen(false); _player.setFullScreen(false);
onMinimize.emit(); onMinimize.emit();
@@ -410,6 +434,7 @@ class VideoDetailView : ConstraintLayout {
if (!_isCasting && !_didStop) { if (!_isCasting && !_didStop) {
setLastPositionMilliseconds(position, true); setLastPositionMilliseconds(position, true);
} }
updatePlaybackTracking(position);
}; };
_player.onVideoClicked.subscribe { _player.onVideoClicked.subscribe {
@@ -540,7 +565,7 @@ class VideoDetailView : ConstraintLayout {
val replyCount = c.replyCount ?: 0; val replyCount = c.replyCount ?: 0;
var metadata = ""; var metadata = "";
if (replyCount > 0) { if (replyCount > 0) {
metadata += "$replyCount replies"; metadata += "$replyCount " + context.getString(R.string.replies);
} }
if (c is PolycentricPlatformComment) { if (c is PolycentricPlatformComment) {
@@ -575,16 +600,78 @@ class VideoDetailView : ConstraintLayout {
_layoutResume.visibility = View.GONE; _layoutResume.visibility = View.GONE;
}; };
_layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
}
}
}
val _trackingUpdateTimeLock = Object();
val _trackingUpdateInterval = 3000;
var _trackingLastUpdateTime = System.currentTimeMillis();
var _trackingLastPosition: Long = 0;
var _trackingLastVideo: IPlatformVideoDetails? = null;
var _trackingTotalWatched: Long = 0;
var _trackingDidCountView: Boolean = false;
var _trackingLastVideoSubscription: Subscription? = null;
fun updatePlaybackTracking(position: Long) {
if(!Settings.instance.subscriptions.allowPlaytimeTracking)
return;
val now = System.currentTimeMillis();
val shouldUpdate = synchronized(_trackingUpdateTimeLock) {
val doUpdate = (now - _trackingLastUpdateTime) > _trackingUpdateInterval;
if(doUpdate)
_trackingLastUpdateTime = now;
return@synchronized doUpdate;
}
if(shouldUpdate) {
val currentVideo = video;
val delta = position - _trackingLastPosition;
_trackingLastPosition = position;
if(currentVideo != null && currentVideo == _trackingLastVideo) {
if(delta > 500 && delta < _trackingUpdateInterval * 1.5) {
_trackingLastVideoSubscription?.let {
Logger.i(TAG, "Subscription [${it.channel.name}] watch time delta [${delta}]" +
"(${"%.2f".format((_trackingTotalWatched / 1000) / currentVideo.duration.toDouble().coerceAtLeast(1.0))})");
it.updatePlayback(currentVideo, (delta / 1000).toInt());
_trackingTotalWatched += delta;
if(!_trackingDidCountView && currentVideo.duration > 0) {
val percentage = (_trackingTotalWatched / 1000) / currentVideo.duration.toDouble();
if(percentage > 0.4) {
Logger.i(TAG, "Subscription [${it.channel.name}] new view");
_trackingDidCountView = true;
it.addPlaybackView();
}
}
it.saveAsync();
};
}
}
else {
if(_trackingLastVideo == null && currentVideo == null)
return;
_trackingLastVideo = currentVideo;
_trackingTotalWatched = 0;
if(currentVideo?.author?.url != null)
_trackingLastVideoSubscription = StateSubscriptions.instance.getSubscription(currentVideo.author.url);
else
_trackingLastVideoSubscription = null;
}
}
} }
fun updateMoreButtons() { 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 { (video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer); _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer);
} }
}, },
if(video?.isLive ?: false) 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 { video?.let {
try { try {
loadLiveChat(it); loadLiveChat(it);
@@ -594,7 +681,7 @@ class VideoDetailView : ConstraintLayout {
} }
} }
} else null, } 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) { if(!allowBackground) {
_player.switchToAudioMode(); _player.switchToAudioMode();
allowBackground = true; allowBackground = true;
@@ -606,31 +693,31 @@ class VideoDetailView : ConstraintLayout {
it.text.text = resources.getString(R.string.background); 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 { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); _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 { video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true"); Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true; preventPictureInPicture = true;
shareVideo(); 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(); this.startPictureInPicture();
fragment.forcePictureInPicture(); fragment.forcePictureInPicture();
//PiPActivity.startPiP(context); //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 { video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
fragment.navigate<BrowserFragment>(url); fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail(); fragment.minimizeVideoDetail();
}; };
}, },
RoundButton(context, R.drawable.ic_refresh, "Reload", "Reload") { RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo(); reloadVideo();
}).filterNotNull(); }).filterNotNull();
if(!_buttonPinStore.getAllValues().any()) if(!_buttonPinStore.getAllValues().any())
@@ -856,7 +943,7 @@ class VideoDetailView : ConstraintLayout {
val subTitleSegments : ArrayList<String> = ArrayList(); val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0) 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) { if(video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0; val diff = video.datetime?.getNowDiffSeconds() ?: 0;
val ago = video.datetime?.toHumanNowDiffString(true) val ago = video.datetime?.toHumanNowDiffString(true)
@@ -870,10 +957,9 @@ class VideoDetailView : ConstraintLayout {
_commentsList.clear(); _commentsList.clear();
_platform.setPlatformFromClientID(video.id.pluginId); _platform.setPlatformFromClientID(video.id.pluginId);
_subTitle.text = subTitleSegments.joinToString(""); _subTitle.text = subTitleSegments.joinToString("");
_channelName.text = video.author.name;
_playWhenReady = true; _playWhenReady = true;
if(video.author.subscribers != null) { 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); (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else { } else {
_channelMeta.text = ""; _channelMeta.text = "";
@@ -897,6 +983,7 @@ class VideoDetailView : ConstraintLayout {
} else { } else {
setPolycentricProfile(null, animate = false); setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id); _taskLoadPolycentricProfile.run(video.author.id);
_channelName.text = video.author.name;
} }
_player.clear(); _player.clear();
@@ -932,7 +1019,7 @@ class VideoDetailView : ConstraintLayout {
} }
if(videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now()) 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) { if (!videoDetail.isLive) {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
@@ -948,6 +1035,18 @@ class VideoDetailView : ConstraintLayout {
if(video is JSVideoDetails) { if(video is JSVideoDetails) {
val me = this; val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) { 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 { try {
val stopwatch = com.futo.platformplayer.debug.Stopwatch() val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker() var tracker = video.getPlaybackTracker()
@@ -964,7 +1063,7 @@ class VideoDetailView : ConstraintLayout {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
withContext(Dispatchers.Main) { 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 +1081,7 @@ class VideoDetailView : ConstraintLayout {
_title.text = video.name; _title.text = video.name;
_channelName.text = video.author.name; _channelName.text = video.author.name;
if(video.author.subscribers != null) { 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); (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else { } else {
_channelMeta.text = ""; _channelMeta.text = "";
@@ -1007,7 +1106,7 @@ class VideoDetailView : ConstraintLayout {
_platform.setPlatformFromClientID(video.id.pluginId); _platform.setPlatformFromClientID(video.id.pluginId);
val subTitleSegments : ArrayList<String> = ArrayList(); val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0) 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) { if(video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0; val diff = video.datetime?.getNowDiffSeconds() ?: 0;
val ago = video.datetime?.toHumanNowDiffString(true) val ago = video.datetime?.toHumanNowDiffString(true)
@@ -1053,7 +1152,9 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
@@ -1166,7 +1267,7 @@ class VideoDetailView : ConstraintLayout {
livePager = StatePlatform.instance.getLiveEvents(video.url); livePager = StatePlatform.instance.getLiveEvents(video.url);
} catch (ex: Throwable) { } catch (ex: Throwable) {
livePager = null; 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); Logger.e(TAG, "Failed to retrieve live chat events", ex);
} }
try { try {
@@ -1177,7 +1278,7 @@ class VideoDetailView : ConstraintLayout {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
liveChatWindow = null; 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); Logger.e(TAG, "Failed to retrieve live chat window", ex);
} }
val liveChat = livePager?.let { val liveChat = livePager?.let {
@@ -1199,7 +1300,7 @@ class VideoDetailView : ConstraintLayout {
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to load live chat", ex); 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"))) //_liveChat?.handleEvents(listOf(LiveEventComment("SYSTEM", null, "Failed to load live chat:\n" + ex.message, "#FF0000")))
/* /*
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -1254,9 +1355,13 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource; _lastVideoSource = videoSource;
_lastAudioSource = audioSource; _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) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to load media", ex); 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) { private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) {
@@ -1274,7 +1379,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)") Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)")
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource)) 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 LiveStream, set to end
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
if (video?.isLive == true) { if (video?.isLive == true) {
@@ -1315,12 +1420,12 @@ class VideoDetailView : ConstraintLayout {
_didTriggerDatasourceError = true; _didTriggerDatasourceError = true;
UIDialogs.showDialog(context, R.drawable.ic_error_pred, UIDialogs.showDialog(context, R.drawable.ic_error_pred,
"Media Error", context.getString(R.string.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.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
null, null,
0, 0,
UIDialogs.Action("No", { _didTriggerDatasourceError = false }), UIDialogs.Action(context.getString(R.string.no), { _didTriggerDatasourceError = false }),
UIDialogs.Action("Yes", { UIDialogs.Action(context.getString(R.string.yes), {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
StatePlatform.instance.reloadClient(context, config.id); StatePlatform.instance.reloadClient(context, config.id);
@@ -1418,8 +1523,9 @@ class VideoDetailView : ConstraintLayout {
?.filter { it.container == bestAudioContainer } ?.filter { it.container == bestAudioContainer }
?.toList() ?: listOf(); ?.toList() ?: listOf();
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, "Quality", null, true, _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle("Playback Rate") } else null, 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 { 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()); 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 -> onClick.subscribe { v ->
@@ -1433,7 +1539,7 @@ class VideoDetailView : ConstraintLayout {
} else null, } else null,
if(localVideoSources?.isNotEmpty() == true) if(localVideoSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Video", "video", SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
*localVideoSources.stream() *localVideoSources.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
@@ -1441,7 +1547,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(localAudioSource?.isNotEmpty() == true) if(localAudioSource?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Audio", "audio", SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource.stream() *localAudioSource.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
@@ -1449,7 +1555,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(localSubtitleSources?.isNotEmpty() == true) if(localSubtitleSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, "Offline Subtitles", "subtitles", SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
*localSubtitleSources *localSubtitleSources
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
@@ -1457,7 +1563,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(liveStreamVideoFormats?.isEmpty() == false) if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, "Stream Video", "video", SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats.stream() *liveStreamVideoFormats.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
@@ -1465,7 +1571,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(liveStreamAudioFormats?.isEmpty() == false) if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, "Stream Audio", "audio", SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats.stream() *liveStreamAudioFormats.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
@@ -1474,7 +1580,7 @@ class VideoDetailView : ConstraintLayout {
else null, else null,
if(bestVideoSources.isNotEmpty()) if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, "Video", "video", SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources.stream() *bestVideoSources.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, 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 +1588,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(bestAudioSources.isNotEmpty()) if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, "Audio", "audio", SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources.stream() *bestAudioSources.stream()
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
@@ -1490,7 +1596,7 @@ class VideoDetailView : ConstraintLayout {
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(video?.subtitles?.isNotEmpty() ?: false && video != null) if(video?.subtitles?.isNotEmpty() ?: false && video != null)
SlideUpMenuGroup(this.context, "Subtitles", "subtitles", SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
*video.subtitles *video.subtitles
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
@@ -1613,12 +1719,13 @@ class VideoDetailView : ConstraintLayout {
private fun handleUnavailableVideo(msg: String? = null) { private fun handleUnavailableVideo(msg: String? = null) {
if (!nextVideo()) { if (!nextVideo()) {
if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1)) 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.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("Back", { UIDialogs.Action(context.getString(R.string.back), {
this@VideoDetailView.onClose.emit(); this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY)
);
} else { } 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) }; video?.let { StatePlatform.instance.clearContentDetailCache(it.url) };
@@ -1870,9 +1977,9 @@ class VideoDetailView : ConstraintLayout {
_player.getGlobalVisibleRect(r); _player.getGlobalVisibleRect(r);
r.right = r.right - _player.paddingEnd; r.right = r.right - _player.paddingEnd;
val playpauseAction = if(_player.playing) 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 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() return PictureInPictureParams.Builder()
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
@@ -1971,14 +2078,24 @@ class VideoDetailView : ConstraintLayout {
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile; _polycentricProfile = cachedPolycentricProfile;
if (cachedPolycentricProfile?.profile == null) { val dp_35 = 35.dp(context.resources)
_layoutMonetization.visibility = View.GONE; val profile = cachedPolycentricProfile?.profile;
_creatorThumbnail.setHarborAvailable(false, animate); val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
return; ?.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; if (profile != null) {
_creatorThumbnail.setHarborAvailable(true, animate); _channelName.text = cachedPolycentricProfile.profile.systemState.username;
_layoutMonetization.visibility = View.VISIBLE;
} else {
_layoutMonetization.visibility = View.GONE;
}
} }
fun setProgressBarOverlayed(isOverlayed: Boolean?) { fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -2051,7 +2168,7 @@ class VideoDetailView : ConstraintLayout {
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); );
} else { } 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> { .exception<ContentNotAvailableYetException> {
@@ -2070,9 +2187,10 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "exception<ScriptImplementationException>", it) Logger.w(TAG, "exception<ScriptImplementationException>", it)
if (!nextVideo()) { 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 { } 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> { .exception<ScriptAgeException> {
@@ -2087,7 +2205,9 @@ class VideoDetailView : ConstraintLayout {
this@VideoDetailView.onClose.emit(); this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
} else { } 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> { .exception<ScriptUnavailableException> {
@@ -2103,7 +2223,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel(); _liveTryJob?.cancel();
_liveTryJob = null; _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> { .exception<Throwable> {
@@ -2115,7 +2235,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel(); _liveTryJob?.cancel();
_liveTryJob = null; _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}); } else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
@@ -2156,7 +2276,7 @@ class VideoDetailView : ConstraintLayout {
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return; val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
fragment.lifecycleScope.launch(Dispatchers.Main){ 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(); _liveTryJob?.cancel();
@@ -2170,7 +2290,7 @@ class VideoDetailView : ConstraintLayout {
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) { if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
if(videoDetail.datetime!! > OffsetDateTime.now()) if(videoDetail.datetime!! > OffsetDateTime.now())
withContext(Dispatchers.Main) { 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); startLiveTry(liveTryVideo);
} }
@@ -2183,7 +2303,7 @@ class VideoDetailView : ConstraintLayout {
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to live try fetch video.", ex); Logger.e(TAG, "Failed to live try fetch video.", ex);
withContext(Dispatchers.Main) { 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(); }; buttonPlayAll.setOnClickListener { onPlayAllClick(); };
buttonShuffle.setOnClickListener { onShuffleClick(); }; buttonShuffle.setOnClickListener { onShuffleClick(); };
if (canEdit()) _buttonEdit.setOnClickListener { onEditClick(); };
_buttonEdit.setOnClickListener { onEditClick(); }; setButtonDownloadVisible(canEdit());
else
_buttonEdit.visibility = View.GONE;
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
@@ -93,7 +91,7 @@ abstract class VideoListEditorView : LinearLayout {
} }
protected fun setVideoCount(videoCount: Int = -1) { 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) { protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
@@ -107,7 +105,7 @@ abstract class VideoListEditorView : LinearLayout {
.into(it); .into(it);
}; };
} else { } else {
_textMetadata.text = "0 videos"; _textMetadata.text = "0 " + context.getString(R.string.videos);
if(_imagePlaylistThumbnail != null) { if(_imagePlaylistThumbnail != null) {
Glide.with(_imagePlaylistThumbnail) Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail) .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); val view = inflater.inflate(R.layout.fragment_overview_top_bar, container, false);
view.findViewById<ImageView>(R.id.app_icon).setOnClickListener { 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); val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
@@ -194,7 +194,7 @@ class SearchTopBarFragment : TopFragment() {
if (editSearch != null) { if (editSearch != null) {
val text = editSearch.text.toString(); val text = editSearch.text.toString();
if (text.length < 3) { if (text.length < 3) {
UIDialogs.toast("Please use at least 3 characters"); UIDialogs.toast(getString(R.string.please_use_at_least_3_characters));
return; return;
} }
@@ -64,12 +64,11 @@ class Logging {
val client = OkHttpClient() val client = OkHttpClient()
val response: Response = client.newCall(request).execute() val response: Response = client.newCall(request).execute()
if (response.isSuccessful) { return if (response.isSuccessful) {
val body = response.body?.string(); response.body?.string();
return if (body != null) Json.decodeFromString<String>(body) else null;
} else { } else {
Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" }; Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" };
return null; null;
} }
} }
} }
@@ -1,17 +1,27 @@
package com.futo.platformplayer.models 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.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.getNowDiffDays import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class Subscription { class Subscription {
var channel: SerializedChannel; 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 //Last found content
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideo : OffsetDateTime = OffsetDateTime.MAX; var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
@@ -32,24 +42,93 @@ class Subscription {
//Last video interval //Last video interval
var uploadInterval : Int = 0; var uploadInterval : Int = 0;
var uploadStreamInterval : Int = 0;
var uploadPostInterval : Int = 0; var uploadPostInterval : Int = 0;
var playbackSeconds: Int = 0;
var playbackViews: Int = 0;
constructor(channel : SerializedChannel) { constructor(channel : SerializedChannel) {
this.channel = channel; this.channel = channel;
} }
fun shouldFetchStreams() = lastLiveStream.getNowDiffDays() < 7; fun shouldFetchVideos() = true;
fun shouldFetchLiveStreams() = lastLiveStream.getNowDiffDays() < 14; fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7;
fun shouldFetchPosts() = lastPost.getNowDiffDays() < 2; fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14;
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2;
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url); fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun save() {
StateSubscriptions.instance.saveSubscription(this);
}
fun saveAsync() {
StateSubscriptions.instance.saveSubscription(this);
}
fun updateChannel(channel: IPlatformChannel) { fun updateChannel(channel: IPlatformChannel) {
this.channel = SerializedChannel.fromChannel(channel); this.channel = SerializedChannel.fromChannel(channel);
} }
fun updateVideoStatus(allVideos: List<IPlatformContent>? = null, liveStreams: List<IPlatformContent>? = null) { fun updatePlayback(content: IPlatformContentDetails, seconds: Int) {
playbackSeconds += seconds;
}
fun addPlaybackView() {
playbackViews += 1;
}
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 isUnstableBuild: Boolean,
val brand: String, val brand: String,
val manufacturer: 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.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -37,7 +38,8 @@ class PolycentricCache {
ContentType.AVATAR.value, ContentType.AVATAR.value,
ContentType.USERNAME.value, ContentType.USERNAME.value,
ContentType.DESCRIPTION.value, ContentType.DESCRIPTION.value,
ContentType.STORE.value ContentType.STORE.value,
ContentType.SERVER.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) }; ).eventsList.map { e -> SignedEvent.fromProto(e) };
@@ -88,8 +90,9 @@ class PolycentricCache {
if (result.profile != null) { if (result.profile != null) {
for (claim in result.profile.ownedClaims) { for (claim in result.profile.ownedClaims) {
val url = claim.claim.resolveChannelUrl() ?: continue; val urls = claim.claim.resolveChannelUrls();
_profileUrlCache.map[url] = result; for (url in urls)
_profileUrlCache.map[url] = result;
} }
} }
@@ -33,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
@@ -54,6 +55,8 @@ import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
/*** /***
* This class contains global context for unconventional cases where obtaining context is hard. * This class contains global context for unconventional cases where obtaining context is hard.
@@ -380,6 +383,18 @@ class StateApp {
fun mainAppStarted(context: Context) { fun mainAppStarted(context: Context) {
Logger.i(TAG, "App started"); Logger.i(TAG, "App started");
//Start loading cache
instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val time = measureTimeMillis {
ChannelContentCache.instance;
}
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to load announcements.", e)
}
}
StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now()) StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now())
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot) if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
@@ -442,7 +457,8 @@ class StateApp {
if (isRateLimitReached) { if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000); delay(5000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
} }
else else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
@@ -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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.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.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails 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? { fun getPlaybackTracker(url: String): IPlaybackTracker? {
val baseClient = getContentClientOrNull(url) ?: return null; val baseClient = getContentClientOrNull(url) ?: return null;
if (baseClient !is JSClient) { if (baseClient !is JSClient) {
@@ -642,11 +651,8 @@ class StatePlatform {
return _scope.async { getChannelLive(url, updateSubscriptions) }; return _scope.async { getChannelLive(url, updateSubscriptions) };
} }
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> { fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl, ignorePlugins);
val clientCapabilities = baseClient.getChannelCapabilities(); val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1) val client = if(usePooledClients > 1)
_channelClientPool.getClientPooled(baseClient, usePooledClients); _channelClientPool.getClientPooled(baseClient, usePooledClients);
else baseClient; else baseClient;
@@ -756,11 +762,25 @@ class StatePlatform {
} }
if(hasChanges) if(hasChanges)
StateSubscriptions.instance.saveSubscription(sub); sub.save();
} }
return pagerResult; 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 { fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel {
val channel = getChannelClient(url).getChannel(url); val channel = getChannelClient(url).getChannel(url);
@@ -784,6 +804,15 @@ class StatePlatform {
return null; 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 getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
fun getPlaylistClient(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})"); ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
@@ -108,16 +108,15 @@ class StatePlugins {
instance.deletePlugin(embedded.key); instance.deletePlugin(embedded.key);
StatePlatform.instance.updateAvailableClients(context); StatePlatform.instance.updateAvailableClients(context);
} }
fun updateEmbeddedPlugins(context: Context) { fun updateEmbeddedPlugins(context: Context, subset: List<String>? = null, force: Boolean = false) {
for(embedded in getEmbeddedSources(context)) { for(embedded in getEmbeddedSources(context).filter { subset == null || subset.contains(it.key) }) {
val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value); val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value);
if(FORCE_REINSTALL_EMBEDDED) if(embeddedConfig != null) {
deletePlugin(embedded.key);
else if(embeddedConfig != null) {
val existing = getPlugin(embedded.key); val existing = getPlugin(embedded.key);
if(existing != null && existing.config.version < embeddedConfig.version ) { if(existing != null && (existing.config.version < embeddedConfig.version || (force || FORCE_REINSTALL_EMBEDDED))) {
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling"); Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
deletePlugin(embedded.key); //deletePlugin(embedded.key);
installEmbeddedPlugin(context, embedded.value)
} }
else if(existing != null && _isFirstEmbedUpdate) else if(existing != null && _isFirstEmbedUpdate)
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})"); Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
@@ -360,6 +359,8 @@ class StatePlugins {
} }
val existing = getPlugin(config.id) val existing = getPlugin(config.id)
val existingAuth = existing?.getAuth();
val existingCaptcha = existing?.getCaptchaData();
if (existing != null) { if (existing != null) {
if(!reinstall) if(!reinstall)
throw IllegalStateException("Plugin with id ${config.id} already exists"); throw IllegalStateException("Plugin with id ${config.id} already exists");
@@ -373,7 +374,7 @@ class StatePlugins {
if(icon != null) if(icon != null)
iconsDir.saveIconBinary(config.id, icon); iconsDir.saveIconBinary(config.id, icon);
_plugins.save(SourcePluginDescriptor(config, null, null, flags)); _plugins.save(SourcePluginDescriptor(config, existingAuth?.toEncrypted(), existingCaptcha?.toEncrypted(), flags));
return null; return null;
} }
catch(ex: Throwable) { 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.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlaceholderPager 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.RefreshDedupContentPager
import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
import com.futo.platformplayer.awaitFirstDeferred import com.futo.platformplayer.awaitFirstDeferred
@@ -130,10 +131,7 @@ class StatePolycentric {
//TODO: Currently abusing subscription concurrency for parallelism //TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency; val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull { val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
if (!StatePlatform.instance.hasEnabledChannelClient(url)) { if (!StatePlatform.instance.hasEnabledChannelClient(url)) {
return@mapNotNull null; return@mapNotNull null;
} }
@@ -146,15 +144,39 @@ class StatePolycentric {
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id }); 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>? { fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
//TODO: Currently abusing subscription concurrency for parallelism //TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency; val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val deferred = profile.ownedClaims.groupBy { it.claim.claimType } val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
.mapNotNull { .mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
val client = StatePlatform.instance.getChannelClientOrNull(url) ?: return@mapNotNull null; val client = StatePlatform.instance.getChannelClientOrNull(url) ?: return@mapNotNull null;
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
@@ -173,12 +195,21 @@ class StatePolycentric {
}) ?: return null; }) ?: return null;
val toAwait = deferred.filter { it.second != finishedPager.first }; 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( return RefreshDedupContentPager(RefreshDistributionContentPager(
listOf(finishedPager.second!!), listOf(finishedPager.second!!),
toAwait.map { it.second }, toAwait.map { it.second },
toAwait.map { PlaceholderPager(5) { PlatformContentPlaceholder(it.first.id) } }), toAwait.map { PlaceholderPager(5) { PlatformContentPlaceholder(it.first.id) } }),
StatePlatform.instance.getEnabledClients().map { it.id } StatePlatform.instance.getEnabledClients().map { it.id }
); );*/
} }
suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> { suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@@ -29,13 +29,17 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.streams.toList
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
/*** /***
@@ -53,7 +57,6 @@ class StateSubscriptions {
private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency()); private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency());
private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>(); private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
private var _globalSubscriptionsLock = Object(); private var _globalSubscriptionsLock = Object();
private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null; private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null;
@@ -62,6 +65,8 @@ class StateSubscriptions {
var globalSubscriptionExceptions: List<Throwable> = listOf() var globalSubscriptionExceptions: List<Throwable> = listOf()
private set; private set;
private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SMART;
private var _lastGlobalSubscriptionProgress: Int = 0; private var _lastGlobalSubscriptionProgress: Int = 0;
private var _lastGlobalSubscriptionTotal: Int = 0; private var _lastGlobalSubscriptionTotal: Int = 0;
val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>(); val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>();
@@ -69,6 +74,15 @@ class StateSubscriptions {
val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>(); val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>();
val onGlobalSubscriptionsException = Event1<List<Throwable>>(); val onGlobalSubscriptionsException = Event1<List<Throwable>>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
fun getOldestUpdateTime(): OffsetDateTime {
val subs = getSubscriptions();
if(subs.size == 0)
return OffsetDateTime.now();
else
return subs.minOf { it.lastVideoUpdate };
}
fun getGlobalSubscriptionProgress(): Pair<Int, Int> { fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal); return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
} }
@@ -165,6 +179,9 @@ class StateSubscriptions {
fun saveSubscription(sub: Subscription) { fun saveSubscription(sub: Subscription) {
_subscriptions.save(sub, false, true); _subscriptions.save(sub, false, true);
} }
fun saveSubscriptionAsync(sub: Subscription) {
_subscriptions.saveAsync(sub, false, true);
}
fun getSubscriptionCount(): Int { fun getSubscriptionCount(): Int {
synchronized(_subscriptions) { synchronized(_subscriptions) {
return _subscriptions.getItems().size; return _subscriptions.getItems().size;
@@ -223,167 +240,30 @@ class StateSubscriptions {
} }
fun getSubscriptionRequestCount(): Map<JSClient, Int> { fun getSubscriptionRequestCount(): Map<JSClient, Int> {
val subs = getSubscriptions(); return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
val pluginReqCounts = mutableMapOf<JSClient, Int>(); .countRequests(getSubscriptions());
}
for(sub in subs) { 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 client = StatePlatform.instance.getChannelClientOrNull(sub.channel.url); val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
if(client !is JSClient)
continue;
val channelCaps = client.getChannelCapabilities(); algo.onProgress.subscribe { progress, total ->
if(!pluginReqCounts.containsKey(client)) onProgress?.invoke(progress, total);
pluginReqCounts[client] = 1; }
algo.onNewCacheHit.subscribe { sub, content ->
}
val usePolycentric = true;
val subUrls = getSubscriptions().parallelStream().map {
if(usePolycentric)
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
else else
pluginReqCounts[client] = pluginReqCounts[client]!! + 1; Pair(it, listOf(it.channel.url));
}.toList().associate { it };
if(channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.shouldFetchStreams()) val result = algo.getSubscriptions(subUrls);
pluginReqCounts[client] = pluginReqCounts[client]!! + 1; return Pair(result.pager, result.exceptions);
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;
}
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();
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);
});
}
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(TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize();
//return Pair(pager, exs);
return Pair(DedupContentPager(pager), exs);
} }
//New Migration //New Migration
@@ -39,7 +39,8 @@ class StateTelemetry {
BuildConfig.IS_UNSTABLE_BUILD, BuildConfig.IS_UNSTABLE_BUILD,
Build.BRAND, Build.BRAND,
Build.MANUFACTURER, Build.MANUFACTURER,
Build.MODEL Build.MODEL,
Build.VERSION.SDK_INT
); );
val headers = hashMapOf( val headers = hashMapOf(
@@ -181,6 +181,12 @@ class ManagedStore<T>{
return ReconstructionResult(successes, exs, builder.messages); return ReconstructionResult(successes, exs, builder.messages);
} }
fun count(): Int {
synchronized(_files) {
return _files.size;
}
}
fun getItems(): List<T> { fun getItems(): List<T> {
synchronized(_files) { synchronized(_files) {
return _files.map { it.key }; return _files.map { it.key };
@@ -221,18 +227,18 @@ class ManagedStore<T>{
} }
fun saveAsync(obj: T, withReconstruction: Boolean = false) { fun saveAsync(obj: T, withReconstruction: Boolean = false, onlyExisting: Boolean = false) {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
if(scope != null) if(scope != null)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
save(obj, withReconstruction); save(obj, withReconstruction, onlyExisting);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to save.", e); Logger.e(TAG, "Failed to save.", e);
} }
}; };
else else
save(obj, withReconstruction); save(obj, withReconstruction, onlyExisting);
} }
fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) { fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
@@ -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,110 @@
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);
};
}
};
};
for(task in allTasks)
task.urgency = calculateUpdateUrgency(task.sub, task.type);
val ordering = allTasks.groupBy { it.client }
.map { Pair(it.key, it.value.sortedBy { it.urgency }) };
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 = (interval * 24) - lastUpdateHoursAgo.toDouble();
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,223 @@
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.EmptyPager
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);
val tasksGrouped = tasks.groupBy { it.client }
val taskCount = tasks.filter { !it.fromCache }.size;
val cacheCount = tasks.size - taskCount;
Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
" Tasks: ${taskCount}\n" +
" Cached: ${cacheCount}");
try {
for(clientTasks in tasksGrouped) {
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
val clientCacheCount = clientTasks.value.size - clientTaskCount;
if(clientCacheCount > 0) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels. (${clientCacheCount} cached)");
}
}
} catch (ex: Throwable){}
val exs: ArrayList<Throwable> = arrayListOf();
val failedPlugins = mutableListOf<String>();
val cachedChannels = mutableListOf<String>()
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
val taskResults = arrayListOf<SubscriptionTaskResult>();
val timeTotal = measureTimeMillis {
for(task in forkTasks) {
try {
val result = task.get();
if(result != null) {
if(result.pager != null)
taskResults.add(result);
else if(result.exception != null) {
val ex = result.exception;
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")
//Cache pagers grouped by channel
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
.map { entry ->
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
val liveTasks = entry.value.filter { !it.task.fromCache };
val cachedTasks = entry.value.filter { it.task.fromCache };
val livePager = if(!liveTasks.isEmpty()) ChannelContentCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, {
onNewCacheHit.emit(sub!!, it);
}) else null;
val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null;
if(livePager != null && cachedPager == null)
return@map livePager;
else if(cachedPager != null && livePager == null)
return@map cachedPager;
else if(cachedPager == null && livePager == null)
return@map EmptyPager();
else
return@map MultiChronoContentPager(listOf(livePager!!, cachedPager!!), true).apply { this.initialize() }
}
val pager = MultiChronoContentPager(groupedPagers, allowFailure, 15);
pager.initialize();
return Result(DedupContentPager(pager), exs);
}
fun executeSubscriptionTasks(tasks: List<SubscriptionTask>, failedPlugins: MutableList<String>, cachedChannels: MutableList<String>): List<ForkJoinTask<SubscriptionTaskResult>> {
val forkTasks = mutableListOf<ForkJoinTask<SubscriptionTaskResult>>();
var finished = 0;
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);
val initialPage = pager.getResults();
task.sub.updateSubscriptionState(task.type, initialPage);
task.sub.save();
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(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, "Subscriptions ignoring 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);
}
}
}
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);
}
return forkTasks;
}
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,
var urgency: Int = 0
);
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); 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) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e) Logger.e(TAG, "Failed to backfill servers.", e)
} }
@@ -93,6 +95,7 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) { fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false); _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
_textAuthor.text = comment.author.name; _textAuthor.text = comment.author.name;
val date = comment.date; val date = comment.date;
@@ -105,7 +108,7 @@ class CommentViewHolder : ViewHolder {
val rating = comment.rating; val rating = comment.rating;
if (rating is RatingLikeDislikes) { 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 { } else {
_layoutComment.alpha = 1.0f; _layoutComment.alpha = 1.0f;
} }
@@ -159,7 +162,7 @@ class CommentViewHolder : ViewHolder {
val replies = comment.replyCount ?: 0; val replies = comment.replyCount ?: 0;
if (!readonly || replies > 0) { if (!readonly || replies > 0) {
_buttonReplies.visibility = View.VISIBLE; _buttonReplies.visibility = View.VISIBLE;
_buttonReplies.text.text = "$replies replies"; _buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies);
} else { } else {
_buttonReplies.visibility = View.GONE; _buttonReplies.visibility = View.GONE;
} }
@@ -38,7 +38,7 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open"; _textSourceSubtitle.text = context.getString(R.string.tap_to_open);
_buttonAdd.setOnClickListener { onAdd.emit(source) } _buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); }; _root.setOnClickListener { onClick.emit(); };
@@ -38,7 +38,7 @@ class DisabledSourceViewHolder : ViewHolder {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open"; _textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client; source = client;
} }
} }
@@ -57,7 +57,7 @@ class EnabledSourceViewHolder : ViewHolder {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name;
_textSourceSubtitle.text = "Tap to open"; _textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client source = client
} }
} }
@@ -89,7 +89,7 @@ class HistoryListViewHolder : ViewHolder {
var metadata = ""; var metadata = "";
if (v.video.viewCount > 0) 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; _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.FeedStyle
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
open class PreviewVideoView : LinearLayout { open class PreviewVideoView : LinearLayout {
@@ -58,11 +59,12 @@ open class PreviewVideoView : LinearLayout {
protected val _exoPlayer: PlayerManager?; protected val _exoPlayer: PlayerManager?;
private val _taskLoadValidClaims = TaskHandler<PlatformID, PolycentricCache.CachedOwnedClaims>(StateApp.instance.scopeGetter, private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
{ PolycentricCache.instance.getValidClaimsAsync(it).await() }) StateApp.instance.scopeGetter,
.success { it -> updateClaimsLayout(it, animate = true) } { PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it); Logger.w(TAG, "Failed to load profile.", it);
}; };
val onVideoClicked = Event2<IPlatformVideo, Long>(); val onVideoClicked = Event2<IPlatformVideo, Long>();
@@ -145,15 +147,7 @@ open class PreviewVideoView : LinearLayout {
open fun bind(content: IPlatformContent) { open fun bind(content: IPlatformContent) {
_taskLoadValidClaims.cancel(); _taskLoadProfile.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);
}
isClickable = true; isClickable = true;
@@ -161,16 +155,25 @@ open class PreviewVideoView : LinearLayout {
stopPreview(); stopPreview();
if(_imageChannel != null) val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
Glide.with(_imageChannel) if (cachedProfile != null) {
.load(content.author.thumbnail) onProfileLoaded(cachedProfile, false);
.placeholder(R.drawable.placeholder_channel_thumbnail) } else {
.into(_imageChannel); _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; _imageChannel?.clipToOutline = true;
_textVideoName.text = content.name; _textVideoName.text = content.name;
_textChannelName.text = content.author.name
_layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(content.id)) VISIBLE else GONE; _layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(content.id)) VISIBLE else GONE;
_platformIndicator.setPlatformFromClientID(content.id.pluginId); _platformIndicator.setPlatformFromClientID(content.id.pluginId);
@@ -178,15 +181,15 @@ open class PreviewVideoView : LinearLayout {
var metadata = "" var metadata = ""
if (content is IPlatformVideo && content.viewCount > 0) { if (content is IPlatformVideo && content.viewCount > 0) {
if(content.isLive) if(content.isLive)
metadata += "${content.viewCount.toHumanNumber()} watching • "; metadata += "${content.viewCount.toHumanNumber()} ${context.getString(R.string.watching)}";
else else
metadata += "${content.viewCount.toHumanNumber()} views • "; metadata += "${content.viewCount.toHumanNumber()} ${context.getString(R.string.views)}";
} }
var timeMeta = ""; var timeMeta = "";
if(isPlanned) { if(isPlanned) {
val ago = content.datetime?.toHumanNowDiffString(true) ?: "" val ago = content.datetime?.toHumanNowDiffString(true) ?: ""
timeMeta = "available in " + ago; timeMeta = context.getString(R.string.available_in) + " ${ago}";
} }
else { else {
val ago = content.datetime?.toHumanNowDiffString() ?: "" val ago = content.datetime?.toHumanNowDiffString() ?: ""
@@ -207,13 +210,13 @@ open class PreviewVideoView : LinearLayout {
if(!isPlanned) if(!isPlanned)
_textVideoDuration.text = video.duration.toHumanTime(false); _textVideoDuration.text = video.duration.toHumanTime(false);
else else
_textVideoDuration.text = "Planned"; _textVideoDuration.text = context.getString(R.string.planned);
_playerVideoThumbnail?.setLive(video.isLive); _playerVideoThumbnail?.setLive(video.isLive);
if(!isPlanned && video.isLive) { if(!isPlanned && video.isLive) {
_containerDuration.visibility = GONE; _containerDuration.visibility = GONE;
_containerLive.visibility = VISIBLE; _containerLive.visibility = VISIBLE;
timeMeta = "LIVE" timeMeta = context.getString(R.string.live_capitalized)
} }
else { else {
_containerLive.visibility = GONE; _containerLive.visibility = GONE;
@@ -296,22 +299,50 @@ open class PreviewVideoView : LinearLayout {
_playerVideoThumbnail?.setMuteChangedListener(callback); _playerVideoThumbnail?.setMuteChangedListener(callback);
} }
private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_neopassAnimator?.cancel(); _neopassAnimator?.cancel();
_neopassAnimator = null; _neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); val profile = cachedPolycentricProfile?.profile;
if (harborAvailable) { if (_creatorThumbnail != null) {
_imageNeopassChannel?.visibility = View.VISIBLE val dp_32 = 32.dp(context.resources);
if (animate) { val avatar = profile?.systemState?.avatar?.selectBestImage(dp_32 * dp_32)
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
_neopassAnimator?.start()
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 { companion object {
@@ -14,7 +14,8 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
private val _confirmationMessage: String; private val _confirmationMessage: String;
var onClick = Event1<Subscription>(); var onClick = Event1<Subscription>();
var sortBy: Int = 0 var onSettings = Event1<Subscription>();
var sortBy: Int = 3
set(value) { set(value) {
field = value; field = value;
updateDataset(); updateDataset();
@@ -33,12 +34,16 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder {
val holder = SubscriptionViewHolder(viewGroup); val holder = SubscriptionViewHolder(viewGroup);
holder.onClick.subscribe(onClick::emit); holder.onClick.subscribe(onClick::emit);
holder.onSettings.subscribe(onSettings::emit);
holder.onTrash.subscribe { holder.onTrash.subscribe {
val sub = holder.subscription ?: return@subscribe; val sub = holder.subscription ?: return@subscribe;
UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, { UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, {
StateSubscriptions.instance.removeSubscription(sub.channel.url); StateSubscriptions.instance.removeSubscription(sub.channel.url);
}); });
}; };
holder.onSettings.subscribe {
onSettings.emit(it);
};
return holder; return holder;
} }
@@ -51,6 +56,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
_sortedDataset = when (sortBy) { _sortedDataset = when (sortBy) {
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name }) 0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name })
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name }) 1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name })
2 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackViews }
3 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackViews }
4 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackSeconds }
5 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds }
else -> throw IllegalStateException("Invalid sorting algorithm selected."); else -> throw IllegalStateException("Invalid sorting algorithm selected.");
}.toList(); }.toList();
@@ -9,6 +9,8 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
@@ -17,6 +19,9 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.platformplayer.toHumanTimeIndicator
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -26,12 +31,14 @@ class SubscriptionViewHolder : ViewHolder {
private val _textName: TextView; private val _textName: TextView;
private val _creatorThumbnail: CreatorThumbnail; private val _creatorThumbnail: CreatorThumbnail;
private val _buttonTrash: ImageButton; private val _buttonTrash: ImageButton;
private val _buttonSettings: ImageButton;
private val _platformIndicator : PlatformIndicator; private val _platformIndicator : PlatformIndicator;
private val _textMeta: TextView;
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>( private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter, StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) }) { PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) } .success { it -> onProfileLoaded(null, it, true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load profile.", it); Logger.w(TAG, "Failed to load profile.", it);
}; };
@@ -41,12 +48,15 @@ class SubscriptionViewHolder : ViewHolder {
var onClick = Event1<Subscription>(); var onClick = Event1<Subscription>();
var onTrash = Event0(); var onTrash = Event0();
var onSettings = Event1<Subscription>();
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) { constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) {
_layoutSubscription = itemView.findViewById(R.id.layout_subscription); _layoutSubscription = itemView.findViewById(R.id.layout_subscription);
_textName = itemView.findViewById(R.id.text_name); _textName = itemView.findViewById(R.id.text_name);
_textMeta = itemView.findViewById(R.id.text_meta);
_creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail); _creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail);
_buttonTrash = itemView.findViewById(R.id.button_trash); _buttonTrash = itemView.findViewById(R.id.button_trash);
_buttonSettings = itemView.findViewById(R.id.button_settings);
_platformIndicator = itemView.findViewById(R.id.platform); _platformIndicator = itemView.findViewById(R.id.platform);
_layoutSubscription.setOnClickListener { _layoutSubscription.setOnClickListener {
@@ -59,6 +69,11 @@ class SubscriptionViewHolder : ViewHolder {
_buttonTrash.setOnClickListener { _buttonTrash.setOnClickListener {
onTrash.emit(); onTrash.emit();
}; };
_buttonSettings.setOnClickListener {
subscription?.let {
onSettings.emit(it);
};
}
} }
fun bind(sub: Subscription) { fun bind(sub: Subscription) {
@@ -68,27 +83,46 @@ class SubscriptionViewHolder : ViewHolder {
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true); val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
if (cachedProfile != null) { if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false); onProfileLoaded(sub, cachedProfile, false);
} else { } else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false); _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id); _taskLoadProfile.run(sub.channel.id);
_textName.text = sub.channel.name;
bindViewMetrics(sub);
} }
_textName.text = sub.channel.name;
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId); _platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
} }
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun onProfileLoaded(sub: Subscription?, cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_46 = 46.dp(itemView.context.resources); val dp_46 = 46.dp(itemView.context.resources);
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46) val profile = cachedPolycentricProfile?.profile;
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) }; 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) { if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate); _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;
}
if(sub != null)
bindViewMetrics(sub)
}
fun bindViewMetrics(sub: Subscription?) {
if(sub == null || !Settings.instance.subscriptions.showWatchMetrics)
_textMeta.text = "";
else
_textMeta.text = listOf(
if(sub.playbackViews > 0) "${sub.playbackViews} view" + (if(sub.playbackViews > 1) "s" else "") else null,
if(sub.playbackSeconds > 0) sub.playbackSeconds.toHumanTimeIndicator() else null
).filterNotNull().joinToString(" · ");
} }
companion object { companion object {
@@ -103,7 +103,7 @@ class VideoListEditorViewHolder : ViewHolder {
var metadata = ""; var metadata = "";
if (v.viewCount > 0) if (v.viewCount > 0)
metadata += "${v.viewCount.toHumanNumber()} views • "; metadata += "${v.viewCount.toHumanNumber()} ${itemView.context.getString(R.string.views)}";
metadata += v.datetime?.toHumanNowDiffString() ?: ""; metadata += v.datetime?.toHumanNowDiffString() ?: "";
_platformIndicator.setPlatformFromClientID(v.id.pluginId); _platformIndicator.setPlatformFromClientID(v.id.pluginId);
@@ -6,13 +6,23 @@ import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.constructs.Event1 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.toHumanNumber
import com.futo.platformplayer.views.adapters.AnyAdapter 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.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton 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>( class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Boolean) : AnyAdapter.AnyViewHolder<PlatformAuthorLink>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_creator, _viewGroup, false)) { 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; private var _authorLink: PlatformAuthorLink? = null;
val onClick = Event1<PlatformAuthorLink>(); 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 { init {
_textName = _view.findViewById(R.id.text_channel_name); _textName = _view.findViewById(R.id.text_channel_name);
_creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); _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) { override fun bind(authorLink: PlatformAuthorLink) {
_textName.text = authorLink.name; _taskLoadProfile.cancel();
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
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) if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L)
_textMetadata.visibility = View.GONE; _textMetadata.visibility = View.GONE;
else { 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; _textMetadata.visibility = View.VISIBLE;
} }
_buttonSubscribe.setSubscribeChannel(authorLink.url); _buttonSubscribe.setSubscribeChannel(authorLink.url);
@@ -58,6 +85,25 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
_authorLink = authorLink; _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 { companion object {
private const val TAG = "CreatorViewHolder"; private const val TAG = "CreatorViewHolder";
} }
@@ -45,7 +45,7 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
override fun bind(playlist: SelectablePlaylist) { override fun bind(playlist: SelectablePlaylist) {
_textName.text = playlist.playlist.name; _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; _checkbox.value = playlist.selected;
val thumbnail = playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(); val thumbnail = playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail();
@@ -57,22 +57,27 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
} else { } else {
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false); _creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_taskLoadProfile.run(subscription.channel.id); _taskLoadProfile.run(subscription.channel.id);
_name.text = subscription.channel.name;
} }
_name.text = subscription.channel.name;
_subscription = subscription; _subscription = subscription;
} }
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_55 = 55.dp(itemView.context.resources) val dp_55 = 55.dp(itemView.context.resources)
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55) val profile = cachedPolycentricProfile?.profile;
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) }; 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) { if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); _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 { _videoDelete.setOnClickListener {
val id = _video?.id ?: return@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); StateDownloads.instance.deleteCachedVideo(id);
}); });
} }
@@ -129,10 +129,10 @@ class AnnouncementView : LinearLayout {
_textClose.text = announcement.cancelName; _textClose.text = announcement.cancelName;
} }
else else
_textClose.text = "Dismiss"; _textClose.text = context.getString(R.string.dismiss);
} }
else else
_textClose.text = "Dismiss"; _textClose.text = context.getString(R.string.dismiss);
when (announcement.announceType) { when (announcement.announceType) {
AnnouncementType.DELETABLE -> { AnnouncementType.DELETABLE -> {
@@ -408,10 +408,10 @@ class GestureControlView : LinearLayout {
val seekOffset: Long = 10000; val seekOffset: Long = 10000;
if (_rewinding) { if (_rewinding) {
_textRewind.text = "${_fastForwardCounter * 10} seconds"; _textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
onSeek.emit(-seekOffset); onSeek.emit(-seekOffset);
} else { } else {
_textFastForward.text = "${_fastForwardCounter * 10} seconds"; _textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
onSeek.emit(seekOffset); 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.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
class BigButton : LinearLayout { open class BigButton : LinearLayout {
private val _root: LinearLayout; private val _root: LinearLayout;
private val _icon: ShapeableImageView; private val _icon: ShapeableImageView;
private val _textPrimary: TextView; private val _textPrimary: TextView;
@@ -78,6 +78,10 @@ class BigButton : LinearLayout {
_textSecondary.text = text; _textSecondary.text = text;
return this; return this;
} }
fun withSecondaryTextMaxLines(lines: Int): BigButton {
_textSecondary.maxLines = lines;
return this;
}
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (resourceId != -1) { if (resourceId != -1) {
@@ -31,12 +31,12 @@ class AddCommentView : LinearLayout {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - _lastClickTime > 3000) { 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 { try {
UIDialogs.showCommentDialog(context, cu, ref) { onCommentAdded.emit(it) }; UIDialogs.showCommentDialog(context, cu, ref) { onCommentAdded.emit(it) };
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to post comment", e); 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.util.AttributeSet
import android.widget.* import android.widget.*
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2 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.Field
import java.lang.reflect.Method 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; override var descriptor: FormField? = null;
private var _obj : Any? = null; private var _obj : Any? = null;
private var _method : Method? = null; private var _method : Method? = null;
@@ -26,17 +34,22 @@ class ButtonField : LinearLayout, IField {
return null; return null;
}; };
private val _title : TextView; //private val _title : TextView;
private val _subtitle : TextView; //private val _subtitle : TextView;
override val onChanged = Event2<IField, Any>(); 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_button, this); //inflate(context, R.layout.field_button, this);
_title = findViewById(R.id.field_title); //_title = findViewById(R.id.field_title);
_subtitle = findViewById(R.id.field_subtitle); //_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) if(_method?.parameterCount == 1)
_method?.invoke(_obj, context); _method?.invoke(_obj, context);
else if(_method?.parameterCount == 2) else if(_method?.parameterCount == 2)
@@ -51,13 +64,17 @@ class ButtonField : LinearLayout, IField {
this._obj = obj; this._obj = obj;
val attrField = method.getAnnotation(FormField::class.java); val attrField = method.getAnnotation(FormField::class.java);
val attrButtonField = method.getAnnotation(FormFieldButton::class.java);
if(attrField != null) { if(attrField != null) {
_title.text = attrField.title; super.withPrimaryText(context.getString(attrField.title))
_subtitle.text = attrField.subtitle; .withSecondaryText(if (attrField.subtitle != -1) context.getString(attrField.subtitle) else "")
.withSecondaryTextMaxLines(2);
descriptor = attrField; descriptor = attrField;
} }
else else
_title.text = method.name; super.withPrimaryText(method.name);
if(attrButtonField != null)
super.withIcon(attrButtonField.drawable, false);
return this; return this;
} }

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