Compare commits

...

109 Commits

Author SHA1 Message Date
Kelvin d7f4dd65e8 Stable refs 2023-11-06 14:58:11 +01:00
Kelvin 599b119e62 Remove plugin interaction on main thread for channels 2023-11-06 14:53:24 +01:00
Kelvin 41176464db Fix missing swipe to refresh on tab switch 2023-11-06 14:43:24 +01:00
Kelvin dd0ad19fb9 NewLine subs import, fix no-recent video subscriptions 2023-11-06 14:25:09 +01:00
Kelvin 430625d2fb Fix icon colors 2023-11-06 13:37:18 +01:00
Kelvin 796cd1a776 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-06 13:20:42 +01:00
Kelvin baa26af0c0 Only show sub toasts when on subs page, WIP import ui 2023-11-06 13:20:33 +01:00
Koen ea0c27936e Fixed videos not automatically going to next video in playlist when casting. 2023-11-05 15:13:57 +01:00
Kelvin 4aade35d19 Grayjay schema channel support 2023-11-04 18:42:04 +01:00
Kelvin 251a5701af Custom grayjay open video url handling 2023-11-04 18:31:01 +01:00
Kelvin 2da3116111 Fix initial selection of subscription settings 2023-11-03 20:07:08 +01:00
Kelvin 4c82fa1a4a Stable refs 2023-11-03 18:25:40 +01:00
Kelvin 7eef6eece2 Primary claim support, fix sub for clients without type 2023-11-03 18:17:04 +01:00
Kelvin 570f32e980 PlatformUrl support 2023-11-03 15:39:27 +01:00
Kelvin 16a0351125 Per-plugin ratelimit setting 2023-11-03 15:15:18 +01:00
Kelvin 2fa9005806 Keep plugin settings on update 2023-11-03 14:46:43 +01:00
Kelvin 25527997fa Fix channels updating while they shouldnt 2023-11-03 14:37:36 +01:00
Kelvin 4655d8369d Reduce subscription calls, Improve subs sorting, Improve view sorting 2023-11-03 13:34:23 +01:00
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
Kelvin c2dce52a5b Fix Twitch live streams on channel, hasMore can now be nullable defaulting to false 2023-10-20 17:49:26 +02:00
Kelvin a2c63c59c5 Hide buy on playstore, margins on captcha button 2023-10-20 17:38:33 +02:00
Kelvin 7e54a2ce3d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 17:20:43 +02:00
Kelvin 5b7fb2c818 Consent reject now works, app now intercepts redirects 2023-10-20 17:20:36 +02:00
Koen da0ac281e2 Added button to open FAQ from settings. 2023-10-20 14:43:01 +02:00
Koen 576b37f64c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 14:34:53 +02:00
Koen 26c2db5023 Handle pager getComments returning null silently. 2023-10-20 14:34:41 +02:00
Kelvin f344dbf35c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 14:34:01 +02:00
Kelvin a04acbd4a5 Home all error fix, multi plugin cookie support, completion url semi-wildcard support, delete captcha button, critical exception support, dev portal can now request captchas. WIP Consent fix 2023-10-20 14:33:37 +02:00
Koen bd48aba8d3 Added text for FeedView which allows users to be informed what to do when sub feed is empty. 2023-10-20 14:14:02 +02:00
Koen 12b73bb248 Maximum import 75 subscriptions at once. 2023-10-20 13:17:25 +02:00
Koen c3ff897ef4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 12:21:33 +02:00
Koen 242728fbe7 Fix deferred null. 2023-10-20 12:20:56 +02:00
Kelvin 14df7c8d43 Missing negative sub hide, youtube null exception catch, autobackup password field type fix 2023-10-20 00:27:25 +02:00
Kelvin 229377bd6e Subscriptions ratelimit and warnings, Nebula login requirement, Subscription fetch setting, -1 sub hide 2023-10-19 22:47:42 +02:00
Kelvin d4317ff06f Merge 2023-10-19 20:08:18 +02:00
Kelvin c70dbb56c8 Wip ratelimiting subs 2023-10-19 20:05:22 +02:00
Koen f9b772b729 Handle captcha exception on PlatformClientPool 2023-10-19 19:25:08 +02:00
Koen bbcc424393 Added missing throwIfCaptcha. 2023-10-19 19:09:33 +02:00
Koen f433cb1280 Fade mostly disliked comments. 2023-10-19 18:55:59 +02:00
Koen 9cf81ad20a Fixed build error. 2023-10-19 16:00:36 +02:00
Kelvin f65e293e45 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-19 15:28:18 +02:00
Kelvin 9a08762e9e Fix nested video serialization, log on login exceptions js 2023-10-19 15:28:14 +02:00
Koen 66dbd20a90 Comment truncation 2023-10-19 14:52:11 +02:00
Koen 8254bcc647 Comment truncation 2023-10-19 14:51:12 +02:00
Koen 51d0f18168 Fixed back button on add source and fixed QR code scanning. 2023-10-19 11:04:45 +02:00
Koen 5dcb535c0f Added Polycentric comment character limit of 5000. 2023-10-19 10:16:15 +02:00
Kelvin b7cbeb3837 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-18 20:11:50 +02:00
Kelvin 2067561c09 Fix dedup in subscriptions feed, Download change directory no longer crashes, Allow uppercase letters in email for payment, Fix developer mode not enabling 2023-10-18 20:11:20 +02:00
Koen 1ac70dba3f Update .gitlab-ci.yml 2023-10-17 21:45:28 +00:00
Kelvin f4370c1bfd Revert playlist ignoring missing source exception 2023-10-17 23:07:20 +02:00
Kelvin 73321ee362 Allow import/restore playlist with missing sources 2023-10-17 21:23:02 +02:00
Kelvin 182c88fc9e Prevent subsequent subscription requests if captcha, Prevent retry dialog in some captcha situations, prevent dup captchas 2023-10-17 20:47:23 +02:00
Koen 9d39d74be5 Fixed wrong variable name 2023-10-17 17:43:59 +02:00
Koen d8d8d6f666 Updated submodule 2023-10-17 17:09:53 +02:00
Kelvin df0504cead Captcha plugin system 2023-10-17 15:25:46 +02:00
Koen 851b547d64 Captcha support. 2023-10-17 13:17:54 +02:00
Koen f49ecf1159 Properly hide refresh layout loader. 2023-10-17 09:41:35 +02:00
Kelvin 081ae1dd88 Move unhandled exception announcement check to correct method 2023-10-16 22:05:47 +02:00
Kelvin 374d9950be Plugin disable only after no ongoing v8 calls to reduce crashes, errors of placeholder loaders now visible, cancel retry on home now removes loader 2023-10-16 22:04:19 +02:00
Kelvin 9ffdf39f13 Permanently stop playlist video download on cancel, Use detailed video download overlay in overviews 2023-10-13 19:09:07 +02:00
Kelvin 8bb1ff87c0 Fix issues with attempting to download sources that are not supported (including mixed playlists) 2023-10-13 18:00:01 +02:00
Kelvin 67e29999ef Add missing use 2023-10-12 19:21:14 +02:00
Kelvin f3f13a71dc New auto-backup storage using the Storage Access Framework, minor dialog tweaks, minor settings ui tweaks 2023-10-12 19:18:56 +02:00
Kelvin 5155423a1e Improve auth doc 2023-10-11 23:48:03 +02:00
Kelvin a7d558e48d Additional docs 2023-10-11 23:30:29 +02:00
Kelvin 7afd75c712 Fix missing next override for headphone controls 2023-10-11 22:50:36 +02:00
Kelvin 10a661ad4c Minor UI tweak, allow for settings reload, async settings load (with loader) 2023-10-11 22:15:52 +02:00
Kelvin 201fe6f0df Minor fix/comment 2023-10-11 18:01:49 +02:00
Kelvin f76a5b5f01 Cleaning up some logs, reducing retry intervals, planned live stream auto refresh, tweak some buffer timings, fixing some scopes 2023-10-11 17:58:04 +02:00
316 changed files with 12683 additions and 2377 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ variables:
stages: stages:
- buildAndDeployApkUnstable - buildAndDeployApkUnstable
- buildAndDeployApkStable - buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable: buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable stage: buildAndDeployApkUnstable
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
- branches - branches
when: manual when: manual
buildAndDeployApkStable: buildAndDeployPlaystore:
stage: buildAndDeployApkStable stage: buildAndDeployPlaystore
script: script:
- sh deploy-playstore.sh - sh deploy-playstore.sh
only: only:
+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
+1 -1
View File
@@ -95,7 +95,7 @@ android {
} }
defaultConfig { defaultConfig {
minSdk 29 minSdk 28
targetSdk 33 targetSdk 33
versionCode gitVersionCode versionCode gitVersionCode
versionName gitVersionName versionName gitVersionName
+26 -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"
@@ -91,6 +92,26 @@
<data android:host="*" /> <data android:host="*" />
<data android:scheme="file" /> <data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" /> <data android:mimeType="application/zip" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
@@ -127,6 +148,10 @@
android:name=".activities.ExceptionActivity" android:name=".activities.ExceptionActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.CaptchaActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.LoginActivity" android:name=".activities.LoginActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
@@ -178,9 +203,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>
@@ -217,6 +217,9 @@ function pluginUpdateTestPlugin(config) {
} }
function pluginLoginTestPlugin() { function pluginLoginTestPlugin() {
return syncGET("/plugin/loginTestPlugin", {}); return syncGET("/plugin/loginTestPlugin", {});
}//captchaLoginTestPlugin
function pluginCaptchaTestPlugin(url, html) {
return syncPOST("/plugin/captchaTestPlugin?url=" + url, {}, html);
} }
function pluginLogoutTestPlugin() { function pluginLogoutTestPlugin() {
return syncGET("/plugin/logoutTestPlugin", {}); return syncGET("/plugin/logoutTestPlugin", {});
+9
View File
@@ -681,6 +681,9 @@
}); });
}, 1000); }, 1000);
}, },
captchaTestPlugin() {
captchaLoginTestPlugin();
},
logoutTestPlugin() { logoutTestPlugin() {
pluginLogoutTestPlugin(); pluginLogoutTestPlugin();
}, },
@@ -838,6 +841,12 @@
this.Testing.lastResultError = ""; this.Testing.lastResultError = "";
} }
catch(ex) { catch(ex) {
if(ex.plugin_type == "CaptchaRequiredException") {
let shouldCaptcha = confirm("Do you want to request captcha?");
if(shouldCaptcha) {
pluginCaptchaTestPlugin(ex.url, ex.body);
}
}
console.error("Failed to run test for " + req.title, ex); console.error("Failed to run test for " + req.title, ex);
this.Testing.lastResult = "" this.Testing.lastResult = ""
if(ex.message) if(ex.message)
+19
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
} }
}; };
@@ -64,6 +70,19 @@ class ScriptException extends Error {
} }
} }
} }
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
this.plugin_type = "CaptchaRequiredException";
this.url = url;
this.body = body;
}
}
class CriticalException extends ScriptException {
constructor(msg) {
super("CriticalException", msg);
}
}
class UnavailableException extends ScriptException { class UnavailableException extends ScriptException {
constructor(msg) { constructor(msg) {
super("UnavailableException", msg); super("UnavailableException", msg);
@@ -0,0 +1,15 @@
package com.futo.platformplayer
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.helpers.VideoHelper
fun IPlatformVideoDetails.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IVideoSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IVideoSourceDescriptor.hasAnySource(): Boolean = this.videoSources.any() || (this is VideoUnMuxedSourceDescriptor && this.audioSources.any());
@@ -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) })
} }
@@ -4,7 +4,9 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.* import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -20,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
@@ -42,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", -2 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) {
@@ -56,14 +60,44 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Submit feedback", FieldForm.BUTTON, R.string.show_faq, FieldForm.BUTTON,
"Give feedback on the application", -1 R.string.get_answers_to_common_questions, -3
) )
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField(
R.string.show_issues, FieldForm.BUTTON,
R.string.a_list_of_user_reported_and_self_reported_issues, -2
)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
/*
@FormField(
R.string.submit_feedback, FieldForm.BUTTON,
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);
val subject = "Feedback Grayjay"; val subject = "Feedback Grayjay";
val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n\n"; val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion 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";
val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body)); val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body));
i.data = data; i.data = data;
@@ -71,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 {
@@ -87,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;
@@ -103,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;
@@ -125,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;
@@ -140,7 +175,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 6) @FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField(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;
@@ -156,26 +195,32 @@ class Settings : FragmentedStorageFileJson() {
}; };
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 7) @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) {
@@ -191,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, "Auto-rotate deadzone in 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;
@@ -221,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{
@@ -245,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;
@@ -263,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 {
@@ -285,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;
@@ -320,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.scopeGetter().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) {
@@ -349,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) {
@@ -391,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) {
@@ -406,19 +451,46 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField("Auto Update", "group", "Configure the auto updater", 12) @FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
var storage = Storage();
@Serializable
class Storage {
var storage_general: String? = null;
var storage_download: String? = null;
fun getStorageGeneralUri(): Uri? = storage_general?.let { Uri.parse(it) };
fun getStorageDownloadUri(): Uri? = storage_download?.let { Uri.parse(it) };
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
}
@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;
@@ -436,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) {
@@ -449,20 +521,21 @@ 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 {
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch; val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
Logger.i(TAG, "Version retrieved $version"); Logger.i(TAG, "Version retrieved $version");
@@ -478,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 {
@@ -496,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 {
@@ -506,55 +579,75 @@ 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()!!); UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
} }
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2) @FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() { 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(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
StateApp.instance.requestFileReadAccess(act, null) {
if(it != null && it.exists()) {
val name = it.name;
val contents = it.readBytes(act);
if(contents != null) {
if(name != null && name.endsWith(".zip", true))
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
}
}
}
}*/
} }
@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();
} }
} }
} }
@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;
} }
@@ -565,6 +658,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;
@@ -15,7 +15,9 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.* import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -90,11 +92,25 @@ class UIDialogs {
} }
fun showAutomaticBackupDialog(context: Context) { fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialog = AutomaticBackupDialog(context); val dialogAction: ()->Unit = {
registerDialogOpened(dialog); val dialog = AutomaticBackupDialog(context);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; registerDialogOpened(dialog);
dialog.show(); dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
else {
dialogAction();
}
} }
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) { fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope); val dialog = AutomaticRestoreDialog(context, scope);
@@ -134,10 +150,10 @@ class UIDialogs {
val buttonView = TextView(context); val buttonView = TextView(context);
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt(); val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt(); val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics); val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
if(actions.size > 1) if(actions.size > 1)
this.marginEnd = dp28; this.marginEnd = if(actions.size > 2) dp14 else dp28;
}; };
buttonView.setTextColor(Color.WHITE); buttonView.setTextColor(Color.WHITE);
buttonView.textSize = 14f; buttonView.textSize = 14f;
@@ -151,8 +167,9 @@ class UIDialogs {
ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red)) ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red))
else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary))
} }
val paddingSpecialButtons = if(actions.size > 2) dp14 else dp28;
if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT) if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT)
buttonView.setPadding(dp28, dp10, dp28, dp10); buttonView.setPadding(paddingSpecialButtons, dp10, paddingSpecialButtons, dp10);
else else
buttonView.setPadding(dp10, dp10, dp10, dp10); buttonView.setPadding(dp10, dp10, dp10, dp10);
@@ -194,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)
); );
@@ -209,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,10 +1,13 @@
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.http.server.handlers.HttpConstantHandler
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
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
@@ -17,7 +20,9 @@ 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.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@@ -29,7 +34,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.lang.IllegalStateException
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -45,7 +50,67 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
fun showDownloadVideoOverlay(contentResolver: ContentResolver, video: IPlatformVideoDetails, container: ViewGroup): SlideUpMenuOverlay? { 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.doNotifications)
menu.selectOption(null, "notifications", true, true);
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? {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@@ -64,43 +129,49 @@ 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;
} }
items.add(SlideUpMenuGroup(container.context, "Video", videoSources, if(!VideoHelper.isDownloadable(video)) {
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
UIDialogs.toast( container.context.getString(R.string.no_downloadable_sources_yet));
return null;
}
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
selectedVideo = null; 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 is IVideoUrlSource } .filter { it.isDownloadable() }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
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()
)); ));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it is IVideoUrlSource }.asIterable(), selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(), Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource; FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
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 { it is IAudioUrlSource } .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;
@@ -111,26 +182,29 @@ class UISlideOverlays {
menu?.selectOption(asources, preferredAudioSource); menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource }.asIterable(), selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context), Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
} }
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources //ContentResolver is required for subtitles..
.map { if(contentResolver != null) {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
if (selectedSubtitle == it) { .map {
selectedSubtitle = null; SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
menu?.selectOption(subtitleSources, null); if (selectedSubtitle == it) {
} else { selectedSubtitle = null;
selectedSubtitle = it; menu?.selectOption(subtitleSources, null);
menu?.selectOption(subtitleSources, it); } else {
} selectedSubtitle = it;
}, false); menu?.selectOption(subtitleSources, it);
})); }
}, false);
}));
}
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);
@@ -139,7 +213,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 {
@@ -153,29 +227,12 @@ class UISlideOverlays {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val subtitleUri = subtitleToDownload.getSubtitlesURI(); val subtitleUri = subtitleToDownload.getSubtitlesURI();
if (subtitleUri != null) { //TODO: Remove uri dependency, should be able to work with raw aswell?
var subtitles: String? = null; if (subtitleUri != null && contentResolver != null) {
if ("file" == subtitleUri.scheme) { val subtitlesRaw = StateDownloads.instance.downloadSubtitles(subtitleToDownload, contentResolver);
val inputStream = contentResolver.openInputStream(subtitleUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
subtitles = reader.use { it.readText() };
}
} else if ("http" == subtitleUri.scheme || "https" == subtitleUri.scheme) {
val client = ManagedHttpClient();
val subtitleResponse = client.get(subtitleUri.toString());
if (!subtitleResponse.isOk) {
throw Exception("Cannot fetch subtitles from source '${subtitleUri}': ${subtitleResponse.code}");
}
subtitles = subtitleResponse.body?.toString()
?: throw Exception("Subtitles are invalid '${subtitleUri}': ${subtitleResponse.code}");
} else {
throw Exception("Unsuported scheme");
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateDownloads.instance.download(video, selectedVideo, selectedAudio, if (subtitles != null) SubtitleRawSource(subtitleToDownload.name, subtitleToDownload.format, subtitles!!) else null); StateDownloads.instance.download(video, selectedVideo, selectedAudio, subtitlesRaw);
} }
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -191,13 +248,44 @@ class UISlideOverlays {
}; };
return menu.apply { show() }; return menu.apply { show() };
} }
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup) { fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
showUnknownVideoDownload("Video", container) { px, bitrate -> val handleUnknownDownload: ()->Unit = {
StateDownloads.instance.download(video, px, bitrate) showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate)
};
}; };
if(!useDetails)
handleUnknownDownload();
else {
val scope = StateApp.instance.scopeOrNull;
if(scope != null) {
val loader = showLoaderOverlay(container.context.getString(R.string.fetching_video_details), container);
scope.launch(Dispatchers.IO) {
try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
if(videoDetails !is IPlatformVideoDetails)
throw IllegalStateException("Not a video details");
withContext(Dispatchers.Main) {
if(showDownloadVideoOverlay(videoDetails, container, StateApp.instance.contextOrNull?.contentResolver) == null)
loader.hide(true);
}
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download));
handleUnknownDownload();
loader.hide(true);
}
}
}
}
else handleUnknownDownload();
}
} }
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);
}; };
} }
@@ -209,7 +297,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),
@@ -217,23 +305,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)
))); )));
@@ -254,12 +342,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 {
@@ -269,14 +357,26 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay { fun showLoaderOverlay(text: String, container: ViewGroup): SlideUpMenuOverlay {
val dp70 = 70.dp(container.context.resources);
val dp15 = 15.dp(container.context.resources);
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
Loader(container.context, true, dp70).apply {
this.setPadding(0, dp15, 0, dp15);
}
), true);
overlay.show();
return overlay;
}
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();
@@ -287,23 +387,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); }, 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();
@@ -311,9 +411,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() };
} }
@@ -325,8 +425,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();
@@ -338,18 +438,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); }, 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();
@@ -357,9 +457,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 {
@@ -377,8 +477,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 }
@@ -390,7 +490,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) {
@@ -398,7 +498,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))
@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build import android.os.Build
import android.os.Looper import android.os.Looper
import android.os.OperationCanceledException import android.os.OperationCanceledException
@@ -15,6 +16,7 @@ import android.view.WindowInsetsController
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformMultiClientPool import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -56,7 +58,7 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
fun warnIfMainThread(context: String) { fun warnIfMainThread(context: String) {
if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper()) if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper())
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace); Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace.joinToString { it.toString() });
} }
fun ensureNotMainThread() { fun ensureNotMainThread() {
@@ -66,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);
@@ -75,6 +83,16 @@ fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPool
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec); fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
fun DocumentFile.copyTo(context: Context, file: DocumentFile) = this.getInputStream(context).use { input ->
file.getOutputStream(context)?.use { output -> input?.copyTo(output) }
};
fun DocumentFile.readBytes(context: Context) = this.getInputStream(context).use { input -> input?.readBytes() };
fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.contentResolver.openOutputStream(this.uri)?.use {
it.write(byteArray);
it.flush();
};
fun loadBitmap(url: String): Bitmap { fun loadBitmap(url: String): Bitmap {
try { try {
@@ -75,10 +75,10 @@ class AddSourceActivity : AppCompatActivity() {
_buttonInstall = findViewById(R.id.button_install); _buttonInstall = findViewById(R.id.button_install);
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
onBackPressed(); finish();
}; };
_buttonCancel.setOnClickListener { _buttonCancel.setOnClickListener {
onBackPressed(); finish();
} }
_buttonInstall.setOnClickListener { _buttonInstall.setOnClickListener {
_config?.let { _config?.let {
@@ -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)
) )
@@ -1,7 +1,10 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
@@ -13,6 +16,32 @@ 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 ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
val url = if (content.startsWith("https://")) {
content
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@let;
}
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url);
};
startActivity(intent);
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -23,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();
@@ -31,21 +61,17 @@ 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)
integrator.setBarcodeImageEnabled(true) integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.initiateScan() _qrCodeResultLauncher.launch(integrator.createScanIntent())
} }
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet.."); UIDialogs.toast(this, getString(R.string.not_implemented_yet));
} }
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -0,0 +1,120 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.WebView
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginCaptchaConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.lang.Exception
import java.util.UUID
class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha);
setNavigationBarColorAndIcons();
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener { finish(); };
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
val config = if(intent.hasExtra("plugin"))
Json.decodeFromString<SourcePluginConfig>(intent.getStringExtra("plugin")!!);
else null;
val captchaConfig = if(config != null)
config.captcha ?: throw IllegalStateException("Plugin has no captcha support");
else if(intent.hasExtra("captcha"))
Json.decodeFromString<SourcePluginCaptchaConfig>(intent.getStringExtra("captcha")!!);
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
val extraUrl = if (intent.hasExtra("url"))
intent.getStringExtra("url");
else null;
val extraBody = if (intent.hasExtra("body"))
intent.getStringExtra("body");
else null;
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
it.invoke(captcha);
}
finish();
};
_webView.settings.domStorageEnabled = true;
_webView.webViewClient = webViewClient;
if(captchaConfig.captchaUrl != null)
_webView.loadUrl(captchaConfig.captchaUrl);
else if(extraUrl != null && extraBody != null)
_webView.loadDataWithBaseURL(extraUrl, extraBody, "text/html", "utf-8", null);
else if(extraUrl != null)
_webView.loadUrl(extraUrl);
else throw IllegalStateException("No valid captcha info provided");
}
override fun finish() {
lifecycleScope.launch(Dispatchers.Main) {
_webView.loadUrl("about:blank");
}
_callback?.let {
_callback = null;
it.invoke(null);
}
super.finish();
}
companion object {
private val TAG = "CaptchaActivity";
private var _callback: ((SourceCaptchaData?) -> Unit)? = null;
private fun getCaptchaIntent(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null): Intent {
val intent = Intent(context, CaptchaActivity::class.java);
if(url != null)
intent.putExtra("url", url);
if(body != null)
intent.putExtra("body", body);
intent.putExtra("plugin", Json.encodeToString(config));
return intent;
}
fun showCaptcha(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null, callback: ((SourceCaptchaData?) -> Unit)? = null) {
_callback = callback;
context.startActivity(getCaptchaIntent(context, config, url, body));
}
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -37,10 +38,11 @@ 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\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" +
Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack"); Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack");
try { try {
val file = File(filesDir, "log.txt"); val file = File(filesDir, "log.txt");
@@ -77,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;
} }
@@ -99,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();
} }
} }
} }
@@ -117,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
@@ -3,7 +3,9 @@ package com.futo.platformplayer.activities
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -68,9 +70,15 @@ class LoginActivity : AppCompatActivity() {
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
} }
} }
//TODO: Required for some...TBD what to do with it. Clear on finish?
_webView.settings.domStorageEnabled = true; _webView.settings.domStorageEnabled = true;
/*
_webView.webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
Logger.w(TAG, "Login Console: " + consoleMessage?.message());
return super.onConsoleMessage(consoleMessage);
}
}*/
_webView.webViewClient = webViewClient; _webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl); _webView.loadUrl(authConfig.loginUrl);
} }
@@ -392,7 +392,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onResume() { override fun onResume() {
super.onResume(); super.onResume();
Logger.i(TAG, "onResume") Logger.v(TAG, "onResume")
val curOrientation = _orientationManager.orientation; val curOrientation = _orientationManager.orientation;
@@ -408,13 +408,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val videoToOpen = StateSaved.instance.videoToOpen; val videoToOpen = StateSaved.instance.videoToOpen;
if (_wasStopped) { if (_wasStopped) {
Logger.i(TAG, "_wasStopped is true");
Logger.i(TAG, "set _wasStopped = false");
_wasStopped = false; _wasStopped = false;
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) { if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false)); navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
_fragVideoDetail.maximizeVideoDetail(true); _fragVideoDetail.maximizeVideoDetail(true);
@@ -427,13 +424,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onPause() { override fun onPause() {
super.onPause(); super.onPause();
Logger.i(TAG, "onPause") Logger.v(TAG, "onPause")
_isVisible = false; _isVisible = false;
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Logger.i(TAG, "_wasStopped = true"); Logger.v(TAG, "_wasStopped = true");
_wasStopped = true; _wasStopped = true;
} }
@@ -462,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" -> {
@@ -481,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/")) {
@@ -496,13 +497,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}; };
startActivity(intent); startActivity(intent);
} }
else if(targetData.startsWith("grayjay://video/")) {
val videoUrl = targetData.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(targetData.startsWith("grayjay://channel/")) {
val channelUrl = targetData.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
} }
"content" -> { "content" -> {
if(!handleContent(targetData, intent.type)) { if(!handleContent(targetData, intent.type)) {
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",
{ }); { });
} }
@@ -512,7 +521,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",
{ }); { });
} }
@@ -522,7 +531,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",
{ }); { });
} }
@@ -532,7 +541,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",
{ }); { });
} }
@@ -541,7 +550,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);
} }
} }
@@ -582,6 +591,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, data); StateBackup.importZipBytes(this, lifecycleScope, data);
return true; return true;
} }
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false; return false;
} }
fun handleFile(file: String): Boolean { fun handleFile(file: String): Boolean {
@@ -599,6 +611,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file)); StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true; return true;
} }
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false; return false;
} }
fun handleReconstruction(recon: String) { fun handleReconstruction(recon: String) {
@@ -606,10 +621,11 @@ 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;
}; };
}; };
val name = when(type) { val name = when(type) {
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type; "Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
else -> type else -> type
@@ -623,6 +639,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUnknownText(text: String): Boolean {
try {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
}
catch(ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(name: String?, json: String): Boolean { fun handleUnknownJson(name: String?, json: String): Boolean {
val context = this; val context = this;
@@ -648,7 +678,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);
} }
/* /*
@@ -722,22 +752,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
_fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT); _fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT);
} }
Logger.i(TAG, "onRestart5");
} }
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED; val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED;
Logger.i(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
_fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); _fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
Logger.i(TAG, "onPictureInPictureModeChanged Ready"); Logger.v(TAG, "onPictureInPictureModeChanged Ready");
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy(); super.onDestroy();
Logger.i(TAG, "onDestroy") Logger.v(TAG, "onDestroy")
_orientationManager.disable(); _orientationManager.disable();
@@ -745,6 +773,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateSaved.instance.setVideoToOpenBlocking(null); StateSaved.instance.setVideoToOpenBlocking(null);
} }
inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T;
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
@@ -899,7 +930,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>(); private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1; private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult( private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
@@ -929,5 +960,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));
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -14,6 +15,7 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,6 +29,16 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText; private lateinit var _editProfile: EditText;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile);
@@ -45,15 +57,20 @@ 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.initiateScan(); integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}; };
_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;
} }
@@ -66,21 +83,9 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents != null) {
val scannedUrl = result.contents;
import(scannedUrl);
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
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;
} }
@@ -96,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;
} }
@@ -119,7 +124,7 @@ 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}'");
} }
} }
@@ -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() {
}
@@ -10,7 +10,10 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -18,6 +21,7 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher { class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm; private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton; private lateinit var _buttonBack: ImageButton;
private lateinit var _loader: Loader;
private lateinit var _devSets: LinearLayout; private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton; private lateinit var _buttonDev: MaterialButton;
@@ -33,9 +37,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back); _buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev); _buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings); _devSets = findViewById(R.id.dev_settings);
_loader = findViewById(R.id.loader);
_form.fromObject(Settings.instance);
_form.onChanged.subscribe { field, value -> _form.onChanged.subscribe { field, value ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues(); _form.setObjectValues();
Settings.instance.save(); Settings.instance.save();
}; };
@@ -47,18 +52,28 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
startActivity(Intent(this, DeveloperActivity::class.java)); startActivity(Intent(this, DeveloperActivity::class.java));
} }
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
}
};
_lastActivity = this; _lastActivity = this;
reloadSettings();
}
fun reloadSettings() {
_loader.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
};
} }
override fun onResume() { override fun onResume() {
@@ -6,6 +6,7 @@ import com.futo.platformplayer.logging.Logger
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@@ -28,7 +29,11 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder; _builderTemplate = builder;
client = builder.build(); client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request());
val response = afterRequest(chain.proceed(request));
return@addNetworkInterceptor response;
}.build();
} }
open fun clone(): ManagedHttpClient { open fun clone(): ManagedHttpClient {
@@ -116,7 +121,7 @@ open class ManagedHttpClient {
fun execute(request : Request) : Response { fun execute(request : Request) : Response {
ensureNotMainThread(); ensureNotMainThread();
beforeRequest(request); //beforeRequest(request);
Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]"); Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]");
@@ -156,23 +161,16 @@ open class ManagedHttpClient {
if(true) if(true)
Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]"); Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]");
afterRequest(request, resp); //afterRequest(request, resp);
return resp; return resp;
} }
//Set Listeners //Set Listeners
fun setOnBeforeRequest(listener : (Request)->Unit) { open fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
this.onBeforeRequest = listener; return request;
} }
fun setOnAfterRequest(listener : (Request, Response)->Unit) { open fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
this.onAfterRequest = listener; return resp;
}
open fun beforeRequest(request: Request) {
onBeforeRequest?.invoke(request);
}
open fun afterRequest(request: Request, resp: Response) {
onAfterRequest?.invoke(request, resp);
} }
@@ -63,7 +63,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
} }
}.start(); }.start();
Logger.i(TAG, "Started ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n")); Logger.i(TAG, "Started HTTP Server ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n"));
} }
@Synchronized @Synchronized
fun stop() { fun stop() {
@@ -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
*/ */
@@ -94,7 +94,10 @@ class LiveChatManager {
if(_pager is JSLiveEventPager) if(_pager is JSLiveEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); if(newEvents.size > 0)
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
else
Logger.v(TAG, "No new Live Events");
_scope.launch(Dispatchers.Main) { _scope.launch(Dispatchers.Main) {
try { try {
@@ -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
) { ) {
} }
@@ -6,17 +6,20 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
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.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
class PlatformClientPool { class PlatformClientPool {
private val _parent: JSClient; private val _parent: JSClient;
private val _pool: HashMap<JSClient, Int> = hashMapOf(); private val _pool: HashMap<JSClient, Int> = hashMapOf();
private var _poolCounter = 0; private var _poolCounter = 0;
private val _poolName: String?;
var isDead: Boolean = false var isDead: Boolean = false
private set; private set;
val onDead = Event2<JSClient, PlatformClientPool>(); val onDead = Event2<JSClient, PlatformClientPool>();
constructor(parentClient: IPlatformClient) { constructor(parentClient: IPlatformClient, name: String? = null) {
_poolName = name;
if(parentClient !is JSClient) if(parentClient !is JSClient)
throw IllegalArgumentException("Pooling only supported for JSClients right now"); throw IllegalArgumentException("Pooling only supported for JSClients right now");
Logger.i(TAG, "Pool for ${parentClient.name} was started"); Logger.i(TAG, "Pool for ${parentClient.name} was started");
@@ -47,8 +50,13 @@ class PlatformClientPool {
_poolCounter++; _poolCounter++;
reserved = _pool.keys.find { !it.isBusy }; reserved = _pool.keys.find { !it.isBusy };
if(reserved == null && _pool.size < capacity) { if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool (${_pool.size + 1}/${capacity})"); Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy(); reserved = _parent.getCopy();
reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
};
reserved?.initialize(); reserved?.initialize();
_pool[reserved!!] = _poolCounter; _pool[reserved!!] = _poolCounter;
} }
@@ -1,12 +1,14 @@
package com.futo.platformplayer.api.media package com.futo.platformplayer.api.media
class PlatformMultiClientPool { class PlatformMultiClientPool {
private val _name: String;
private val _maxCap: Int; private val _maxCap: Int;
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf(); private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
private var _isFake = false; private var _isFake = false;
constructor(maxCap: Int = -1) { constructor(name: String, maxCap: Int = -1) {
_name = name;
_maxCap = if(maxCap > 0) _maxCap = if(maxCap > 0)
maxCap maxCap
else 99; else 99;
@@ -17,7 +19,7 @@ class PlatformMultiClientPool {
return parentClient; return parentClient;
val pool = synchronized(_clientPools) { val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient)) if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply { _clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
this.onDead.subscribe { client, pool -> this.onDead.subscribe { client, pool ->
synchronized(_clientPools) { synchronized(_clientPools) {
if(_clientPools[parentClient] == pool) if(_clientPools[parentClient] == pool)
@@ -27,6 +27,7 @@ class ResultCapabilities(
const val TYPE_VIDEOS = "VIDEOS"; const val TYPE_VIDEOS = "VIDEOS";
const val TYPE_STREAMS = "STREAMS"; const val TYPE_STREAMS = "STREAMS";
const val TYPE_LIVE = "LIVE"; const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -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;
}
}
}
@@ -39,4 +39,8 @@ class PolycentricPlatformComment : IPlatformComment {
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment { fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount); return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
} }
companion object {
val MAX_COMMENT_SIZE = 2000
}
} }
@@ -4,7 +4,7 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import java.time.OffsetDateTime import java.time.OffsetDateTime
class PlatformContentPlaceholder(pluginId: String): IPlatformContent { class PlatformContentPlaceholder(pluginId: String, exception: Throwable? = null): IPlatformContent {
override val contentType: ContentType = ContentType.PLACEHOLDER; override val contentType: ContentType = ContentType.PLACEHOLDER;
override val id: PlatformID = PlatformID("", null, pluginId); override val id: PlatformID = PlatformID("", null, pluginId);
override val name: String = ""; override val name: String = "";
@@ -12,4 +12,5 @@ class PlatformContentPlaceholder(pluginId: String): IPlatformContent {
override val shareUrl: String = ""; override val shareUrl: String = "";
override val datetime: OffsetDateTime? = null; override val datetime: OffsetDateTime? = null;
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null); override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null);
val error: Throwable? = exception
} }
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?, override val contentProvider: String?,
override val contentThumbnails: Thumbnails override val contentThumbnails: Thumbnails
) : IPlatformNestedContent, SerializedPlatformContent { ) : IPlatformNestedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id; override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
override val contentSupported: Boolean get() = contentPlugin != null; override val contentSupported: Boolean get() = contentPlugin != null;
@@ -7,6 +7,7 @@ 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
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.states.StateApp
import java.util.* import java.util.*
class DevJSClient : JSClient { class DevJSClient : JSClient {
@@ -15,29 +16,44 @@ class DevJSClient : JSClient {
private val _devScript: String; private val _devScript: String;
private var _auth: SourceAuth? = null; private var _auth: SourceAuth? = null;
private var _captcha: SourceCaptchaData? = null;
val devID: String; val devID: String;
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), listOf("DEV")), null, script) { constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV")), null, script) {
_devScript = script; _devScript = script;
_auth = auth; _auth = auth;
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
}
} }
constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) { //TODO: Misisng auth/captcha pass on purpose?
constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) {
_devScript = script; _devScript = script;
_auth = auth; _auth = auth;
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
}
} }
fun setCaptcha(captcha: SourceCaptchaData? = null) {
_captcha = captcha;
}
fun setAuth(auth: SourceAuth? = null) { fun setAuth(auth: SourceAuth? = null) {
_auth = auth; _auth = auth;
} }
fun recreate(context: Context): DevJSClient { fun recreate(context: Context): DevJSClient {
return DevJSClient(context, config, _devScript, _auth, devID); return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
} }
override fun getCopy(): JSClient { override fun getCopy(): JSClient {
return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID); return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
} }
override fun initialize() { override fun initialize() {
@@ -4,6 +4,7 @@ import android.content.Context
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueBoolean import com.caoccao.javet.values.primitive.V8ValueBoolean
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueNull
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
@@ -14,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
@@ -23,9 +25,14 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.internal.* import com.futo.platformplayer.api.media.platforms.js.internal.*
import com.futo.platformplayer.api.media.platforms.js.models.* import com.futo.platformplayer.api.media.platforms.js.models.*
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -59,6 +66,7 @@ open class JSClient : IPlatformClient {
private var _enabled: Boolean = false; private var _enabled: Boolean = false;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
private val _injectedSaveState: String?; private val _injectedSaveState: String?;
@@ -84,7 +92,21 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit;
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
if(settingsRateLimit > 0) {
if(pluginRateLimit != null)
return settingsRateLimit.coerceAtMost(pluginRateLimit);
else
return settingsRateLimit;
}
else
return pluginRateLimit;
}
val onDisabled = Event1<JSClient>(); val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context; this._context = context;
@@ -93,10 +115,11 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this); _client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth); _clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth); _plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
@@ -108,6 +131,11 @@ open class JSClient : IPlatformClient {
} }
else else
throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available"); throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available");
_plugin.onScriptException.subscribe {
if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it);
};
} }
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
this._context = context; this._context = context;
@@ -116,15 +144,21 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this); _client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth); _clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth); _plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script); _plugin.withScript(script);
_script = script; _script = script;
_plugin.onScriptException.subscribe {
if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it);
};
} }
open fun getCopy(): JSClient { open fun getCopy(): JSClient {
@@ -161,6 +195,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 {
@@ -394,6 +429,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)")
@@ -413,8 +459,11 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith { override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSCommentPager(config, plugin, val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
plugin.executeTyped("source.getComments(${Json.encodeToString(url)})")); if (pager !is V8ValueObject) { //TODO: Maybe solve this better
return@isBusyWith EmptyPager<IPlatformComment>();
}
return@isBusyWith JSCommentPager(config, plugin, pager);
} }
@JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment") @JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment")
@JSDocsParameter("comment", "Comment object that was returned by getComments") @JSDocsParameter("comment", "Comment object that was returned by getComments")
@@ -535,7 +584,7 @@ open class JSClient : IPlatformClient {
if(it.containsKey(claimType)) { if(it.containsKey(claimType)) {
val templates = it[claimType]; val templates = it[claimType];
if(templates != null) if(templates != null)
for(value in values.keys.sortedBy { it }) { for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
if(templates.containsKey(value)) { if(templates.containsKey(value)) {
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!); return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
} }
@@ -545,6 +594,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 {
@@ -561,11 +627,13 @@ open class JSClient : IPlatformClient {
} }
private fun announcePluginUnhandledException(method: String, ex: Throwable) { private fun announcePluginUnhandledException(method: String, ex: Throwable) {
if(ex is PluginEngineException)
return;
try { try {
StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}", StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}",
"Plugin ${config.name} encountered an error in [${method}]", "Plugin ${config.name} encountered an error in [${method}]",
"${ex.message}\nPlease contact the plugin developer", "${ex.message}\nPlease contact the plugin developer",
AnnouncementType.RECURRING, AnnouncementType.SESSION_RECURRING,
OffsetDateTime.now()); OffsetDateTime.now());
} }
catch(_: Throwable) {} catch(_: Throwable) {}
@@ -0,0 +1,49 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
}
companion object {
val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
}
fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Serializable
@Serializable
class SourcePluginCaptchaConfig(
val captchaUrl: String? = null,
val completionUrl: String? = null,
val cookiesToFind: List<String>? = null,
val userAgent: String? = null,
val cookiesExclOthers: Boolean = true
)
@@ -35,14 +35,18 @@ class SourcePluginConfig(
val settings: List<Setting> = listOf(), val settings: List<Setting> = listOf(),
var captcha: SourcePluginCaptchaConfig? = null,
val authentication: SourcePluginAuthConfig? = null, val authentication: SourcePluginAuthConfig? = null,
var sourceUrl: String? = null, var sourceUrl: String? = null,
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var platformUrl: String? = null,
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf() var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -1,7 +1,9 @@
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.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -13,22 +15,28 @@ class SourcePluginDescriptor {
var appSettings: AppPluginSettings = AppPluginSettings(); var appSettings: AppPluginSettings = AppPluginSettings();
var authEncrypted: String? var authEncrypted: String? = null
private set;
var captchaEncrypted: String? = null
private set; private set;
val flags: List<String>; val flags: List<String>;
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
val onAuthChanged = Event0(); val onAuthChanged = Event0();
@kotlinx.serialization.Transient
val onCaptchaChanged = Event0();
constructor(config :SourcePluginConfig, authEncrypted: String? = null) { constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null) {
this.config = config; this.config = config;
this.authEncrypted = authEncrypted; this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = listOf(); this.flags = listOf();
} }
constructor(config :SourcePluginConfig, authEncrypted: String? = null, flags: List<String>) { constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>) {
this.config = config; this.config = config;
this.authEncrypted = authEncrypted; this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = flags; this.flags = flags;
} }
@@ -41,6 +49,13 @@ class SourcePluginDescriptor {
return map; return map;
} }
fun updateCaptcha(captcha: SourceCaptchaData?) {
captchaEncrypted = captcha?.toEncrypted();
onCaptchaChanged.emit();
}
fun getCaptchaData(): SourceCaptchaData? {
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
}
fun updateAuth(str: SourceAuth?) { fun updateAuth(str: SourceAuth?) {
authEncrypted = str?.toEncrypted(); authEncrypted = str?.toEncrypted();
@@ -53,18 +68,41 @@ 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;
} }
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
var rateLimitSubs: Int = 0;
fun getSubRateLimit(): Int {
return when(rateLimitSubs) {
0 -> -1
1 -> 25
2 -> 50
3 -> 75
4 -> 100
5 -> 125
6 -> 150
7 -> 200
else -> -1
}
}
}
fun loadDefaults(config: SourcePluginConfig) { fun loadDefaults(config: SourcePluginConfig) {
@@ -5,90 +5,108 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
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.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient { class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?; private val _jsClient: JSClient?;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
var doUpdateCookies: Boolean = true; var doUpdateCookies: Boolean = true;
var doApplyCookies: Boolean = true; var doApplyCookies: Boolean = true;
var doAllowNewCookies: Boolean = true; var doAllowNewCookies: Boolean = true;
val isLoggedIn: Boolean get() = _auth != null; val isLoggedIn: Boolean get() = _auth != null;
private var _currentCookieMap: HashMap<String, HashMap<String, String>>?; private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null) : super() { constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
_jsClient = jsClient; _jsClient = jsClient;
_auth = auth; _auth = auth;
_captcha = captcha;
_currentCookieMap = hashMapOf();
if(!auth?.cookieMap.isNullOrEmpty()) { if(!auth?.cookieMap.isNullOrEmpty()) {
_currentCookieMap = hashMapOf();
for(domainCookies in auth!!.cookieMap!!) for(domainCookies in auth!!.cookieMap!!)
_currentCookieMap!!.put(domainCookies.key, HashMap(domainCookies.value)); _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
} }
else _currentCookieMap = null; if(!captcha?.cookieMap.isNullOrEmpty()) {
for(domainCookies in captcha!!.cookieMap!!) {
if(_currentCookieMap.containsKey(domainCookies.key))
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
else
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
}
}
} }
override fun clone(): ManagedHttpClient { override fun clone(): ManagedHttpClient {
val newClient = JSHttpClient(_jsClient, _auth); val newClient = JSHttpClient(_jsClient, _auth);
newClient._currentCookieMap = if(_currentCookieMap != null) newClient._currentCookieMap = if(_currentCookieMap != null)
HashMap(_currentCookieMap!!.toList().associate { Pair(it.first, HashMap(it.second)) }) HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
else else
null; hashMapOf();
return newClient; return newClient;
} }
override fun beforeRequest(request: Request) { override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
val domain = request.url.host.lowercase();
val auth = _auth; val auth = _auth;
if (auth != null) {
val domain = Uri.parse(request.url).host!!.lowercase();
val newBuilder = if(auth != null || doApplyCookies)
request.newBuilder();
else
null;
if (auth != null) {
//TODO: Possibly add doApplyHeaders //TODO: Possibly add doApplyHeaders
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries }) for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
request.headers[header.key] = header.value; newBuilder?.header(header.key, header.value);
}
if(doApplyCookies) { if(doApplyCookies) {
if (!_currentCookieMap.isNullOrEmpty()) { if (!_currentCookieMap.isNullOrEmpty()) {
val cookiesToApply = hashMapOf<String, String>(); val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap!!) { synchronized(_currentCookieMap!!) {
for(cookie in _currentCookieMap!! for(cookie in _currentCookieMap!!
.filter { domain.matchesDomain(it.key) } .filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() }) .flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second; cookiesToApply[cookie.first] = cookie.second;
}; };
if(cookiesToApply.size > 0) { if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
request.headers["Cookie"] = cookieString;
} val existingCookies = request.headers["Cookie"];
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) }); if(!existingCookies.isNullOrEmpty())
newBuilder?.header("Cookie", existingCookies.trim(';') + "; " + cookieString);
else
newBuilder?.header("Cookie", cookieString);
} }
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
} }
} }
_jsClient?.validateUrlOrThrow(request.url); _jsClient?.validateUrlOrThrow(request.url.toString());
super.beforeRequest(request) return newBuilder?.let { it.build() } ?: request;
} }
override fun afterRequest(request: Request, resp: Response) { override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
super.afterRequest(request, resp)
if(doUpdateCookies) { if(doUpdateCookies) {
val domain = Uri.parse(request.url).host!!.lowercase(); val domain = resp.request.url.host.lowercase();
val domainParts = domain!!.split("."); val domainParts = domain.split(".");
val defaultCookieDomain = val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString("."); "." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in resp.headers) { for (header in resp.headers) {
if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") { if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
val newCookies = cookieStringToMap(header.value); //val newCookies = cookieStringToMap(header.second.split("; "));
for (cookie in newCookies) { val cookie = cookieStringToPair(header.second);
val endIndex = cookie.value.indexOf(";"); //for (cookie in newCookies) {
var cookieValue = cookie.value; var cookieValue = cookie.second;
var domainToUse = domain; var domainToUse = domain;
if (endIndex > 0) { if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
val cookieParts = cookie.value.split(";"); val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0) if (cookieParts.size == 0)
continue; continue;
cookieValue = cookieParts[0].trim(); cookieValue = cookieParts[0].trim();
@@ -114,24 +132,29 @@ class JSHttpClient : ManagedHttpClient {
_currentCookieMap!!.put(domainToUse, newMap) _currentCookieMap!!.put(domainToUse, newMap)
newMap; newMap;
} }
if(cookieMap.containsKey(cookie.key) || doAllowNewCookies) if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap.put(cookie.key, cookieValue); cookieMap.put(cookie.first, cookieValue);
} //}
} }
} }
} }
return resp;
} }
private fun cookieStringToMap(parts: List<String>): Map<String, String> { private fun cookieStringToMap(parts: List<String>): Map<String, String> {
val map = hashMapOf<String, String>(); val map = hashMapOf<String, String>();
for(cookie in parts) { for(cookie in parts) {
val cookieKey = cookie.substring(0, cookie.indexOf("=")); val pair = cookieStringToPair(cookie)
val cookieVal = cookie.substring(cookie.indexOf("=") + 1); map.put(pair.first, pair.second);
map.put(cookieKey.trim(), cookieVal.trim());
} }
return map; return map;
} }
private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
return Pair(cookieKey.trim(), cookieVal.trim());
}
//Prints out code for test reproduction.. //Prints out code for test reproduction..
fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) { fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) {
@@ -155,4 +178,5 @@ class JSHttpClient : ManagedHttpClient {
Logger.i("Testing", code); Logger.i("Testing", code);
} }
} }
@@ -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);
};
}
}
}
@@ -42,7 +42,6 @@ open class JSContent : IPlatformContent, IPluginSourced {
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName)); id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString(); name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
Logger.i("JSContent", "name=$name");
author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName)); author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName));
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong(); val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
@@ -7,6 +7,7 @@ import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.warnIfMainThread import com.futo.platformplayer.warnIfMainThread
@@ -27,7 +28,7 @@ abstract class JSPager<T> : IPager<T> {
this.pager = pager; this.pager = pager;
this.config = config; this.config = config;
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
getResults(); getResults();
} }
@@ -45,7 +46,7 @@ abstract class JSPager<T> : IPager<T> {
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
pager.invoke("nextPage", arrayOf<Any>()); pager.invoke("nextPage", arrayOf<Any>());
}; };
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
_resultChanged = true; _resultChanged = true;
/* /*
try { try {
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueNull
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
@@ -99,8 +100,11 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return getCommentsJS(client); return getCommentsJS(client);
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>()); val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
return null;
return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager); return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager);
} }
} }
@@ -9,7 +9,7 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.orNull import com.futo.platformplayer.orNull
class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSource { class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
override val container : String get() = "application/vnd.apple.mpegurl"; override val container : String get() = "application/vnd.apple.mpegurl";
override val codec: String = "HLS"; override val codec: String = "HLS";
override val name : String; override val name : String;
@@ -31,9 +31,6 @@ class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSou
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
} }
override fun getAudioUrl(): String {
return url;
}
companion object { companion object {
fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) }; fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) };
@@ -7,7 +7,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource { class JSHLSManifestSource : IHLSManifestSource, JSSource {
override val width : Int = 0; override val width : Int = 0;
override val height : Int = 0; override val height : Int = 0;
override val container : String get() = "application/vnd.apple.mpegurl"; override val container : String get() = "application/vnd.apple.mpegurl";
@@ -28,8 +28,4 @@ class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource {
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
} }
override fun getVideoUrl(): String {
return url;
}
} }
@@ -25,7 +25,8 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
} }
override fun hasMorePages(): Boolean = _basePager.hasMorePages(); override fun hasMorePages(): Boolean =
_basePager.hasMorePages();
override fun nextPage() { override fun nextPage() {
_basePager.nextPage() _basePager.nextPage()
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
@@ -74,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()));
@@ -7,7 +7,8 @@ import java.util.stream.IntStream
* A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item * A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item
*/ */
class MultiChronoContentPager : MultiPager<IPlatformContent> { class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false) : super(pagers.map { it }.toList(), allowFailure) {} 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;
}
}
@@ -16,7 +16,7 @@ abstract class MultiPager<T> : IPager<T> {
protected val _subSinglePagers : MutableList<SingleItemPager<T>>; protected val _subSinglePagers : MutableList<SingleItemPager<T>>;
protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf(); protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf();
private val _pageSize : Int = 9; private var _pageSize : Int = 9;
private var _didInitialize = false; private var _didInitialize = false;
@@ -27,7 +27,8 @@ abstract class MultiPager<T> : IPager<T> {
val totalPagers: Int get() = _pagers.size; val totalPagers: Int get() = _pagers.size;
constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false) { constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false, pageSize: Int = 9) {
this._pageSize = pageSize;
this.allowFailure = allowFailure; this.allowFailure = allowFailure;
_pagers = pagers.toMutableList(); _pagers = pagers.toMutableList();
_subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList(); _subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList();
@@ -137,11 +137,11 @@ abstract class MultiParallelPager<T> : IPager<T>, IAsyncPager<T> {
} }
} }
} }
Logger.i(TAG, "Pager prepare in ${timeForPage}ms"); Logger.v(TAG, "Pager prepare in ${timeForPage}ms");
val timeAwait = measureTimeMillis { val timeAwait = measureTimeMillis {
_currentResults = results.map { it.await() }.mapNotNull { it }; _currentResults = results.map { it.await() }.mapNotNull { it };
}; };
Logger.i(TAG, "Pager load in ${timeAwait}ms"); Logger.v(TAG, "Pager load in ${timeAwait}ms");
_currentResultExceptions = exceptions; _currentResultExceptions = exceptions;
return _currentResults; return _currentResults;
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media.structures package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -37,8 +38,12 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
synchronized(_pending) { synchronized(_pending) {
_pending.remove(pendingPager); _pending.remove(pendingPager);
} }
if(error != null) if(error != null) {
onPagerError.emit(error); onPagerError.emit(error);
val replacing = _placeHolderPagersPaired[pendingPager];
if(replacing != null)
updatePager(null, replacing, error);
}
else else
updatePager(pendingPager.getCompleted()); updatePager(pendingPager.getCompleted());
} }
@@ -60,10 +65,26 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() }; override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() };
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() }; override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?) { private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
if(pagerToAdd == null)
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());
@@ -6,11 +6,11 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
* A placeholder pager simply generates PlatformContent by some creator function. * A placeholder pager simply generates PlatformContent by some creator function.
*/ */
class PlaceholderPager : IPager<IPlatformContent> { class PlaceholderPager : IPager<IPlatformContent> {
private val _creator: ()->IPlatformContent; val placeholderFactory: ()->IPlatformContent;
private val _pageSize: Int; private val _pageSize: Int;
constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) { constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) {
_creator = placeholderCreator; placeholderFactory = placeholderCreator;
_pageSize = pageSize; _pageSize = pageSize;
} }
@@ -18,7 +18,7 @@ class PlaceholderPager : IPager<IPlatformContent> {
override fun getResults(): List<IPlatformContent> { override fun getResults(): List<IPlatformContent> {
val pages = ArrayList<IPlatformContent>(); val pages = ArrayList<IPlatformContent>();
for(item in 1.._pageSize) for(item in 1.._pageSize)
pages.add(_creator()); pages.add(placeholderFactory());
return pages; return pages;
} }
override fun hasMorePages(): Boolean = true; override fun hasMorePages(): Boolean = true;
@@ -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> {
@@ -111,10 +155,10 @@ class ChannelContentCache {
init { init {
val results = pager.getResults(); val results = pager.getResults();
Logger.i(TAG, "Caching ${results.size} subscription initial results"); 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
@@ -64,7 +65,7 @@ class StateCasting {
} }
override fun serviceResolved(event: ServiceEvent) { override fun serviceResolved(event: ServiceEvent) {
Logger.i(TAG, "ChromeCast service resolved: " + event.info); Logger.v(TAG, "ChromeCast service resolved: " + event.info);
addOrUpdateDevice(event); addOrUpdateDevice(event);
} }
@@ -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);
} }
} }
@@ -8,6 +8,10 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
protected val _conditionalListeners = mutableListOf<TaggedHandler<ConditionalHandler>>(); protected val _conditionalListeners = mutableListOf<TaggedHandler<ConditionalHandler>>();
protected val _listeners = mutableListOf<TaggedHandler<Handler>>(); protected val _listeners = mutableListOf<TaggedHandler<Handler>>();
fun hasListeners(): Boolean =
synchronized(_listeners){_listeners.isNotEmpty()} ||
synchronized(_conditionalListeners){_conditionalListeners.isNotEmpty()};
fun subscribeConditional(listener: ConditionalHandler) { fun subscribeConditional(listener: ConditionalHandler) {
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
_conditionalListeners.add(TaggedHandler(listener)); _conditionalListeners.add(TaggedHandler(listener));
@@ -65,10 +69,7 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
class Event0() : EventBase<(()->Unit), (()->Boolean)>() { class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean { fun emit() : Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -76,6 +77,7 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(); handler.handler.invoke();
} }
@@ -85,17 +87,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
} }
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() { class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean { fun emit(value : T1): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value); handled = handled || conditional.handler.invoke(value);
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value); handler.handler.invoke(value);
} }
@@ -105,10 +104,7 @@ class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
} }
class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() { class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean { fun emit(value1 : T1, value2 : T2): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -116,6 +112,7 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value1, value2); handler.handler.invoke(value1, value2);
} }
@@ -126,10 +123,7 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Boolean)>() { class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Boolean)>() {
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean { fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -137,6 +131,7 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value1, value2, value3); handler.handler.invoke(value1, value2, value3);
} }
@@ -57,6 +57,7 @@ class TaskHandler<TParameter, TResult> {
fun run(parameter: TParameter) { fun run(parameter: TParameter) {
val id = ++_idGenerator; val id = ++_idGenerator;
var handled = false;
_scope().launch(_dispatcher) { _scope().launch(_dispatcher) {
if (id != _idGenerator) if (id != _idGenerator)
return@launch; return@launch;
@@ -67,35 +68,53 @@ class TaskHandler<TParameter, TResult> {
return@launch; return@launch;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (id != _idGenerator) if (id != _idGenerator) {
handled = true;
return@withContext; return@withContext;
}
try { try {
onSuccess.emit(result); onSuccess.emit(result);
handled = true;
} }
catch (e: Throwable) { catch (e: Throwable) {
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
onError.emit(e, parameter); onError.emit(e, parameter);
handled = true;
} }
} }
} }
catch (e: Throwable) { catch (e: Throwable) {
Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message); Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message);
if (id != _idGenerator) if (id != _idGenerator) {
handled = true;
return@launch; return@launch;
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
handled = true;
if (id != _idGenerator) if (id != _idGenerator)
return@withContext; return@withContext;
if (!onError.emit(e, parameter)) { if (!onError.emit(e, parameter)) {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
} else { } else {
Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
} }
} }
} }
} }/*.invokeOnCompletion { //Commented for now, because it doesn't fix the bug it was intended to fix, but might want it later anyway
if(!handled) {
if(it is CancellationException) {
Logger.w(TAG, "Detected unhandled TaskHandler due to cancellation, forwarding cancellation");
onError.emit(it, parameter);
}
else {
//TODO: Forward exception?
Logger.w(TAG, "Detected unhandled TaskHandler due to [${it}]", it);
}
}
}*/
} }
@Synchronized @Synchronized
@@ -1,6 +1,7 @@
package com.futo.platformplayer.developer package com.futo.platformplayer.developer
import android.content.Context import android.content.Context
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.LoginActivity import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
@@ -201,6 +202,28 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
} }
} }
@HttpPOST("/plugin/captchaTestPlugin")
fun pluginCaptchaTestPlugin(context: HttpContext) {
val config = _testPlugin?.config as SourcePluginConfig;
val url = context.query.get("url")
val html = context.readContentString();
try {
val captchaConfig = config.captcha;
if(captchaConfig == null) {
context.respondCode(403, "This plugin doesn't support captcha");
return;
}
CaptchaActivity.showCaptcha(StateApp.instance.context, config, url, html) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, it), JSHttpClient(null, null, it));
};
context.respondCode(200, "Captcha started");
}
catch(ex: Throwable) {
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
}
}
@HttpGET("/plugin/loginTestPlugin") @HttpGET("/plugin/loginTestPlugin")
fun pluginLoginTestPlugin(context: HttpContext) { fun pluginLoginTestPlugin(context: HttpContext) {
val config = _testPlugin?.config as SourcePluginConfig; val config = _testPlugin?.config as SourcePluginConfig;
@@ -416,7 +439,7 @@ class DeveloperEndpoints(private val context: Context) {
val resp = _client.get(body.url!!, body.headers); val resp = _client.get(body.url!!, body.headers);
context.respondCode(200, context.respondCode(200,
Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.code, resp.body?.string())), Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())),
context.query.getOrDefault("CT", "text/plain")); context.query.getOrDefault("CT", "text/plain"));
} }
catch(ex: Exception) { catch(ex: Exception) {
@@ -11,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -58,13 +59,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
} }
clearFocus(); clearFocus();
dismiss(); dismiss();
Logger.i(TAG, "Set AutoBackupPassword"); Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString(); Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true; Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save(); Settings.instance.save();
UIDialogs.toast(context, "AutoBackup enabled"); UIDialogs.toast(context, "AutoBackup enabled");
try { try {
StateBackup.startAutomaticBackup(true); StateBackup.startAutomaticBackup(true);
} }
@@ -2,12 +2,17 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable
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
import android.widget.* import android.widget.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID 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.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
@@ -32,6 +37,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
private lateinit var _buttonCancel: MaterialButton; private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editComment: EditText; private lateinit var _editComment: EditText;
private lateinit var _inputMethodManager: InputMethodManager; private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _textCharacterCount: TextView;
private lateinit var _textCharacterCountMax: TextView;
val onCommentAdded = Event1<IPlatformComment>(); val onCommentAdded = Event1<IPlatformComment>();
@@ -42,6 +49,26 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCancel = findViewById(R.id.button_cancel); _buttonCancel = findViewById(R.id.button_cancel);
_buttonCreate = findViewById(R.id.button_create); _buttonCreate = findViewById(R.id.button_create);
_editComment = findViewById(R.id.edit_comment); _editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max);
_editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
_textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
_textCharacterCount.setTextColor(Color.RED);
_textCharacterCountMax.setTextColor(Color.RED);
_buttonCreate.alpha = 0.4f;
} else {
_textCharacterCount.setTextColor(Color.WHITE);
_textCharacterCountMax.setTextColor(Color.WHITE);
_buttonCreate.alpha = 1.0f;
}
}
});
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@@ -53,13 +80,20 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCreate.setOnClickListener { _buttonCreate.setOnClickListener {
clearFocus(); clearFocus();
if (_editComment.text.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
UIDialogs.toast(context, "Comment should be less than 5000 characters");
return@setOnClickListener;
}
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, null, ref)
StateApp.instance.scopeGetter().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);
} }
@@ -1,8 +1,19 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class PlaylistDownloadDescriptor( data class PlaylistDownloadDescriptor(
val id: String, val id: String,
val targetPxCount: Long?, val targetPxCount: Long?,
val targetBitrate: Long? val targetBitrate: Long?
); ) {
var preventDownload: MutableList<String> = arrayListOf();
fun getPreventDownloadList(): List<String> = synchronized(preventDownload){ preventDownload };
fun shouldDownload(video: IPlatformVideo): Boolean {
synchronized(preventDownload) {
return !preventDownload.contains(video.url);
}
}
}
@@ -13,12 +13,16 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.hasAnySource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -27,7 +31,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.CancellationException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
@@ -147,27 +150,37 @@ class VideoDownload {
if(original !is IPlatformVideoDetails) if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?"); throw IllegalStateException("Original content is not media?");
if(original.video.hasAnySource() && !original.isDownloadable()) {
Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}");
throw DownloadException("Unsupported video for downloading", false);
}
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) { if(videoSource == null && targetPixelCount != null) {
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf()) val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
?: throw IllegalStateException("Could not find a valid video source for video"); // ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is IVideoUrlSource) if(vsource != null) {
videoSource = VideoUrlSource.fromUrlSource(vsource); if (vsource is IVideoUrlSource)
else videoSource = VideoUrlSource.fromUrlSource(vsource);
throw IllegalStateException("Download video source is not a url source"); else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
} }
if(audioSource == null && targetBitrate != null) { if(audioSource == null && targetBitrate != null) {
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount) val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
?: if(videoSource != null ) null ?: if(videoSource != null ) null
else throw IllegalStateException("Could not find a valid audio source for video"); else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null) if(asource == null)
audioSource = null; audioSource = null;
else if(asource is IAudioUrlSource) else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource); audioSource = AudioUrlSource.fromUrlSource(asource);
else else
throw IllegalStateException("Download audio source is not a url source"); throw DownloadException("Audio source is not supported for downloading (yet)", false);
} }
if(videoSource == null && audioSource == null)
throw DownloadException("No valid sources found for video/audio");
} }
} }
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope { suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
@@ -358,7 +371,7 @@ class VideoDownload {
} }
if (isCancelled) if (isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled");
} while (read > 0); } while (read > 0);
lastSpeed = 0; lastSpeed = 0;
@@ -410,7 +423,7 @@ class VideoDownload {
} }
if(isCancelled) if(isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled", null);
} }
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
} }
@@ -1,7 +1,6 @@
package com.futo.platformplayer.engine package com.futo.platformplayer.engine
import android.content.Context import android.content.Context
import android.os.Looper
import com.caoccao.javet.exceptions.JavetCompilationException import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Host
@@ -18,9 +17,7 @@ import com.futo.platformplayer.engine.exceptions.*
import com.futo.platformplayer.engine.internal.V8Converter import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.* import com.futo.platformplayer.engine.packages.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import kotlinx.coroutines.*
class V8Plugin { class V8Plugin {
val config: IV8PluginConfig; val config: IV8PluginConfig;
@@ -31,14 +28,31 @@ class V8Plugin {
val httpClient: ManagedHttpClient get() = _client; val httpClient: ManagedHttpClient get() = _client;
val httpClientAuth: ManagedHttpClient get() = _clientAuth; val httpClientAuth: ManagedHttpClient get() = _clientAuth;
private val _runtimeLock = Object();
var _runtime : V8Runtime? = null; var _runtime : V8Runtime? = null;
private val _deps : LinkedHashMap<String, String> = LinkedHashMap(); private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf(); private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null; private var _script : String? = null;
var isStopped = true;
val onStopped = Event1<V8Plugin>(); val onStopped = Event1<V8Plugin>();
//TODO: Implement a more universal isBusy system for plugins + JSClient + pooling? TBD if propagation would be beneficial
private val _busyCounterLock = Object();
private var _busyCounter = 0;
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
/**
* Called before a busy counter is about to be removed.
* Is primarily used to prevent additional calls to dead runtimes.
*
* Parameter is the busy count after this execution
*/
val afterBusy = Event1<Int>();
val onScriptException = Event1<ScriptException>();
constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) { constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) {
this._client = client; this._client = client;
this._clientAuth = clientAuth; this._clientAuth = clientAuth;
@@ -81,7 +95,7 @@ class V8Plugin {
fun start() { fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script"); val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(this) { synchronized(_runtimeLock) {
if (_runtime != null) if (_runtime != null)
return; return;
@@ -121,19 +135,25 @@ class V8Plugin {
catchScriptErrors("Plugin[${config.name}]") { catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid() it.getExecutor(script).executeVoid()
}; };
isStopped = false;
} }
} }
} }
fun stop(){ fun stop(){
Logger.i(TAG, "Stopping plugin [${config.name}]"); Logger.i(TAG, "Stopping plugin [${config.name}]");
synchronized(this) { isStopped = true;
_runtime?.let { whenNotBusy {
_runtime = null; synchronized(_runtimeLock) {
if(!it.isClosed && !it.isDead) isStopped = true;
it.close(); _runtime?.let {
}; _runtime = null;
if(!it.isClosed && !it.isDead)
it.close();
Logger.i(TAG, "Stopped plugin [${config.name}]");
};
}
onStopped.emit(this);
} }
onStopped.emit(this);
} }
fun execute(js: String) : V8Value { fun execute(js: String) : V8Value {
@@ -141,14 +161,53 @@ class V8Plugin {
} }
fun <T : V8Value> executeTyped(js: String) : T { fun <T : V8Value> executeTyped(js: String) : T {
warnIfMainThread("V8Plugin.executeTyped"); warnIfMainThread("V8Plugin.executeTyped");
if(isStopped)
throw PluginEngineStoppedException(config, "Instance is stopped", js);
synchronized(_busyCounterLock) {
_busyCounter++;
}
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() }; try {
return catchScriptErrors("Plugin[${config.name}]", js) {
runtime.getExecutor(js).execute()
};
}
finally {
synchronized(_busyCounterLock) {
//Free busy *after* afterBusy calls are done to prevent calls on dead runtimes
try {
afterBusy.emit(_busyCounter - 1);
}
catch(ex: Throwable) {
Logger.e(TAG, "Unhandled V8Plugin.afterBusy", ex);
}
_busyCounter--;
}
}
} }
fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value }; fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value };
fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value }; fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value };
fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value }; fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value };
fun whenNotBusy(handler: (V8Plugin)->Unit) {
synchronized(_busyCounterLock) {
if(_busyCounter == 0)
handler(this);
else {
val tag = Object();
afterBusy.subscribe(tag) {
if(it == 0) {
Logger.w(TAG, "V8Plugin afterBusy handled");
afterBusy.remove(tag);
handler(this);
}
}
}
}
}
private fun getPackage(context: Context, packageName: String): V8Package { private fun getPackage(context: Context, packageName: String): V8Package {
//TODO: Auto get all package types? //TODO: Auto get all package types?
return when(packageName) { return when(packageName) {
@@ -160,7 +219,13 @@ class V8Plugin {
} }
fun <T : Any> catchScriptErrors(context: String, code: String? = null, handle: ()->T): T { fun <T : Any> catchScriptErrors(context: String, code: String? = null, handle: ()->T): T {
return catchScriptErrors(this.config, context, code, handle); try {
return catchScriptErrors(this.config, context, code, handle);
}
catch(ex: ScriptException) {
onScriptException.emit(ex);
throw ex;
}
} }
companion object { companion object {
@@ -185,7 +250,7 @@ class V8Plugin {
if(result is V8ValueObject) { if(result is V8ValueObject) {
val type = result.getString("plugin_type"); val type = result.getString("plugin_type");
if(type != null && type.endsWith("Exception")) if(type != null && type.endsWith("Exception"))
Companion.throwExceptionFromV8( throwExceptionFromV8(
config, config,
result.getOrThrow(config, "plugin_type", "V8Plugin"), result.getOrThrow(config, "plugin_type", "V8Plugin"),
result.getOrThrow(config, "message", "V8Plugin"), result.getOrThrow(config, "message", "V8Plugin"),
@@ -202,19 +267,28 @@ class V8Plugin {
throw ScriptCompilationException(config, "Compilation: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped); throw ScriptCompilationException(config, "Compilation: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
} }
catch(executeEx: JavetExecutionException) { catch(executeEx: JavetExecutionException) {
val exMessage = extractJSExceptionMessage(executeEx); if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) {
val pluginType = executeEx.scriptingError.context["plugin_type"].toString();
if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) //Captcha
if (pluginType == "CaptchaRequiredException") {
throw ScriptCaptchaRequiredException(config,
executeEx.scriptingError.context["url"]?.toString(),
executeEx.scriptingError.context["body"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Others
throwExceptionFromV8( throwExceptionFromV8(
config, config,
executeEx.scriptingError.context["plugin_type"].toString(), pluginType,
(exMessage ?: ""), (extractJSExceptionMessage(executeEx) ?: ""),
executeEx, executeEx,
executeEx.scriptingError?.stack, executeEx.scriptingError?.stack,
codeStripped codeStripped
); );
}
throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped); throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
} }
catch(ex: Exception) { catch(ex: Exception) {
throw ex; throw ex;
@@ -224,6 +298,7 @@ class V8Plugin {
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) { private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
when(pluginType) { when(pluginType) {
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code); "ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code); "AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code); "UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code); "ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
open class PluginEngineException(config: IV8PluginConfig, error: String, code: String? = null) : PluginException(config, error, null, code) {
}
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
class PluginEngineStoppedException(config: IV8PluginConfig, error: String, code: String? = null) : PluginEngineException(config, error, code) {
}
@@ -0,0 +1,18 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String?, val body: String?, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, "Captcha required", ex, stack, code) {
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
val contextName = "ScriptCaptchaRequiredException";
return ScriptCaptchaRequiredException(config,
obj.getOrDefault<String>(config, "url", contextName, null),
obj.getOrDefault<String>(config, "body", contextName, null));
}
}
}
@@ -0,0 +1,17 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
return ScriptCriticalException(config, obj.getOrThrow(config, "message", "ScriptCriticalException"));
}
}
}
@@ -108,11 +108,12 @@ class PackageHttp: V8Package {
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class BridgeHttpResponse(val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable { class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable {
val isOk = code >= 200 && code < 300; val isOk = code >= 200 && code < 300;
override fun toV8(runtime: V8Runtime): V8Value? { override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject(); val obj = runtime.createV8ValueObject();
obj.set("url", url);
obj.set("code", code); obj.set("code", code);
obj.set("body", body); obj.set("body", body);
obj.set("headers", headers); obj.set("headers", headers);
@@ -227,7 +228,7 @@ class PackageHttp: V8Package {
val resp = client.requestMethod(method, url, headers); val resp = client.requestMethod(method, url, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse(method, url, resp.code, resp.headers, responseBody); logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -241,7 +242,7 @@ class PackageHttp: V8Package {
val resp = client.requestMethod(method, url, body, headers); val resp = client.requestMethod(method, url, body, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse(method, url, resp.code, resp.headers, responseBody); logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -256,7 +257,7 @@ class PackageHttp: V8Package {
val resp = client.get(url, headers); val resp = client.get(url, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse("GET", url, resp.code, resp.headers, responseBody); logResponse("GET", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -270,7 +271,7 @@ class PackageHttp: V8Package {
val resp = client.post(url, body, headers); val resp = client.post(url, body, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse("POST", url, resp.code, resp.headers, responseBody); logResponse("POST", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -367,7 +368,7 @@ class PackageHttp: V8Package {
} }
//Forward timeouts //Forward timeouts
catch(ex: SocketTimeoutException) { catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null); return BridgeHttpResponse("", 408, null);
} }
} }
} }
@@ -461,7 +462,7 @@ class PackageHttp: V8Package {
} }
//Forward timeouts //Forward timeouts
catch(ex: SocketTimeoutException) { catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null); return BridgeHttpResponse("", 408, null);
} }
} }
@@ -0,0 +1,12 @@
package com.futo.platformplayer.exceptions
class DownloadException : Throwable {
val isRetryable: Boolean;
constructor(innerException: Throwable, retryable: Boolean = true): super(innerException) {
isRetryable = retryable;
}
constructor(msg: String, retryable: Boolean = true): super(msg) {
isRetryable = retryable;
}
}
@@ -0,0 +1,9 @@
package com.futo.platformplayer.exceptions
class RateLimitException : Throwable {
val pluginIds: List<String>;
constructor(pluginIds: List<String>): super() {
this.pluginIds = pluginIds ?: listOf();
}
}
@@ -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 = "${channel.subscribers.toHumanNumber()} subscribers"; 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,13 +25,16 @@ 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
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
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
@@ -73,10 +76,17 @@ 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);
}.exception<Throwable> { val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
setPager(pager);
}
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(TAG, "Failed to load initial videos.", it); Logger.w(TAG, "Failed to load initial videos.", it);
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }); UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
}; };
@@ -245,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)
@@ -254,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));
}
} }
} }
} }
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -52,9 +53,10 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
_authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail)); _authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail));
adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1)); adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1));
loadNext(); loadNext();
}.exceptionWithParameter<Throwable> { ex, para -> }.exception<ScriptCaptchaRequiredException> { }
.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
@@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager import com.futo.futopay.PaymentManager
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -59,18 +60,21 @@ 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);
} }
} }
_buttonBuy.setOnClickListener { if(!BuildConfig.IS_PLAYSTORE_BUILD)
buy(); _buttonBuy.setOnClickListener {
} buy();
}
else
_buttonBuy.visibility = View.GONE;
_buttonPaid.setOnClickListener { _buttonPaid.setOnClickListener {
paid(); paid();
} }
@@ -103,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;
} }
@@ -123,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) {
@@ -350,24 +386,22 @@ class ChannelFragment : MainFragment() {
}); });
}); });
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url); _fragment.lifecycleScope.launch(Dispatchers.IO) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
buttons.add(Pair(R.drawable.ic_search) { withContext(Dispatchers.Main) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
}); buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
});
_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 = "${channel.subscribers.toHumanNumber()} subscribers"; _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 +415,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);
} }
}; };
} }
@@ -15,7 +15,9 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager 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.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
@@ -86,9 +88,9 @@ class ContentSearchResultsFragment : MainFragment() {
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
} }
}) })
.success { loadedResult(it); } .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() });
} }
} }
@@ -100,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);
}
} }
} }
} }
@@ -144,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);
}; };
} }
} }
@@ -13,6 +13,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager 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.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
@@ -56,6 +57,7 @@ class CreatorSearchResultsFragment : MainFragment() {
constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) { constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) }) _taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) })
.success { loadedResult(it); } .success { loadedResult(it); }
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it); Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
@@ -69,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);
@@ -39,6 +39,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _spinnerSortBy: Spinner; private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout; private val _containerSortBy: LinearLayout;
private val _tagsView: TagsView; private val _tagsView: TagsView;
private val _textCentered: TextView;
protected val _toolbarContentView: LinearLayout; protected val _toolbarContentView: LinearLayout;
@@ -68,6 +69,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
this.fragment = fragment; this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this); inflater.inflate(R.layout.fragment_feed, this);
_textCentered = findViewById(R.id.text_centered);
_progress_bar = findViewById(R.id.progress_bar); _progress_bar = findViewById(R.id.progress_bar);
_progress_bar.inactiveColor = Color.TRANSPARENT; _progress_bar.inactiveColor = Color.TRANSPARENT;
@@ -120,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();
@@ -139,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() });
@@ -169,6 +180,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_recyclerResults.addOnScrollListener(_scrollListener); _recyclerResults.addOnScrollListener(_scrollListener);
} }
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
fun onResume() { fun onResume() {
//Reload the pager if the plugin was killed //Reload the pager if the plugin was killed
val pager = recyclerData.pager; val pager = recyclerData.pager;
@@ -250,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) {
@@ -327,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}")
}; };
}; };
} }
@@ -8,21 +8,27 @@ import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
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.api.media.models.video.IPlatformVideo
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.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager 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.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -92,25 +98,29 @@ class HomeFragment : MainFragment() {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
}) })
.success { loadedResult(it); } .success { loadedResult(it); }
.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(ChannelFragment.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(ChannelFragment.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();
setLoading(false);
};
}; };
} }
@@ -131,6 +141,8 @@ class HomeFragment : MainFragment() {
} else { } else {
setLoading(false); setLoading(false);
} }
finishRefreshLayoutLoader();
} }
override fun reload() { override fun reload() {
@@ -147,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);
@@ -69,6 +69,8 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _currentLoadIndex = 0; private var _currentLoadIndex = 0;
private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>; private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>;
private var _counter: Int = 0;
private var _limitToastShown = false;
constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
@@ -104,6 +106,7 @@ class ImportSubscriptionsFragment : MainFragment() {
setLoading(false); setLoading(false);
_taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link -> _taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link ->
_counter++;
val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false); val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false);
return@TaskHandler channel; return@TaskHandler channel;
}).success { }).success {
@@ -113,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();
}; };
@@ -124,6 +127,8 @@ class ImportSubscriptionsFragment : MainFragment() {
} }
fun onShown(parameter: Any ?, isBack: Boolean) { fun onShown(parameter: Any ?, isBack: Boolean) {
_counter = 0;
_limitToastShown = false;
updateSelected(); updateSelected();
val itemsRemoved = _items.size; val itemsRemoved = _items.size;
@@ -142,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();
}; };
} }
@@ -157,6 +162,15 @@ class ImportSubscriptionsFragment : MainFragment() {
private fun load() { private fun load() {
setLoading(true); setLoading(true);
if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) {
_limitToastShown = true;
UIDialogs.toast(context, "Stopped after {requestCount} to avoid rate limit, re-enter to import rest".replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
}
setLoading(false);
return;
}
_taskLoadChannel.run(_links[_currentLoadIndex]); _taskLoadChannel.run(_links[_currentLoadIndex]);
} }
@@ -173,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);
@@ -196,6 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 100;
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();
}; };

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