mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ac70dba3f | |||
| f4370c1bfd | |||
| 73321ee362 | |||
| 182c88fc9e | |||
| 9d39d74be5 | |||
| d8d8d6f666 | |||
| df0504cead | |||
| 851b547d64 | |||
| f49ecf1159 | |||
| 081ae1dd88 | |||
| 374d9950be | |||
| 9ffdf39f13 | |||
| 8bb1ff87c0 | |||
| 67e29999ef | |||
| f3f13a71dc | |||
| 5155423a1e | |||
| a7d558e48d | |||
| 7afd75c712 | |||
| 10a661ad4c | |||
| 201fe6f0df | |||
| f76a5b5f01 |
+3
-2
@@ -4,6 +4,7 @@ variables:
|
||||
stages:
|
||||
- buildAndDeployApkUnstable
|
||||
- buildAndDeployApkStable
|
||||
- buildAndDeployPlaystore
|
||||
|
||||
buildAndDeployApkUnstable:
|
||||
stage: buildAndDeployApkUnstable
|
||||
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
|
||||
- branches
|
||||
when: manual
|
||||
|
||||
buildAndDeployApkStable:
|
||||
stage: buildAndDeployApkStable
|
||||
buildAndDeployPlaystore:
|
||||
stage: buildAndDeployPlaystore
|
||||
script:
|
||||
- sh deploy-playstore.sh
|
||||
only:
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
android:name=".activities.ExceptionActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.CaptchaActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
||||
@@ -64,6 +64,14 @@ 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 UnavailableException extends ScriptException {
|
||||
constructor(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());
|
||||
@@ -333,7 +333,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
"Submit logs to help us narrow down issues", 1
|
||||
)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -406,6 +406,33 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField("External Storage", FieldForm.GROUP, "", 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("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
|
||||
fun changeStorageGeneral() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||
}
|
||||
}
|
||||
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
|
||||
fun changeStorageDownload() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField("Auto Update", "group", "Configure the auto updater", 12)
|
||||
var autoUpdate = AutoUpdate();
|
||||
@Serializable
|
||||
@@ -462,7 +489,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun viewChangelog() {
|
||||
UIDialogs.toast("Retrieving changelog");
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
|
||||
Logger.i(TAG, "Version retrieved $version");
|
||||
@@ -511,7 +538,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
|
||||
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)
|
||||
fun restoreAutomaticBackup() {
|
||||
@@ -542,6 +571,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Licenses cleared, might require app restart");
|
||||
it.reloadSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.dialogs.*
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -90,11 +92,25 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showAutomaticBackupDialog(context: Context) {
|
||||
val dialog = AutomaticBackupDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
||||
val dialogAction: ()->Unit = {
|
||||
val dialog = AutomaticBackupDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
||||
dialog.show();
|
||||
};
|
||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
|
||||
UIDialogs.Action("Cancel", {}), //To nothing
|
||||
UIDialogs.Action("Override", {
|
||||
dialogAction();
|
||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||
UIDialogs.Action("Restore", {
|
||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
else {
|
||||
dialogAction();
|
||||
}
|
||||
}
|
||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||
val dialog = AutomaticRestoreDialog(context, scope);
|
||||
@@ -134,10 +150,10 @@ class UIDialogs {
|
||||
val buttonView = TextView(context);
|
||||
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 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 {
|
||||
if(actions.size > 1)
|
||||
this.marginEnd = dp28;
|
||||
this.marginEnd = if(actions.size > 2) dp14 else dp28;
|
||||
};
|
||||
buttonView.setTextColor(Color.WHITE);
|
||||
buttonView.textSize = 14f;
|
||||
@@ -151,8 +167,9 @@ class UIDialogs {
|
||||
ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red))
|
||||
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)
|
||||
buttonView.setPadding(dp28, dp10, dp28, dp10);
|
||||
buttonView.setPadding(paddingSpecialButtons, dp10, paddingSpecialButtons, dp10);
|
||||
else
|
||||
buttonView.setPadding(dp10, dp10, dp10, dp10);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.ContentResolver
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
@@ -18,6 +17,7 @@ import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
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.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -45,7 +45,7 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
|
||||
fun showDownloadVideoOverlay(contentResolver: ContentResolver, video: IPlatformVideoDetails, container: ViewGroup): SlideUpMenuOverlay? {
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
@@ -68,6 +68,12 @@ class UISlideOverlays {
|
||||
return null;
|
||||
}
|
||||
|
||||
if(!VideoHelper.isDownloadable(video)) {
|
||||
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
|
||||
UIDialogs.toast( "No downloadable sources (yet)");
|
||||
return null;
|
||||
}
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, "Video", videoSources,
|
||||
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", {
|
||||
selectedVideo = null;
|
||||
@@ -76,7 +82,7 @@ class UISlideOverlays {
|
||||
menu?.setOk("Download");
|
||||
}, false)) +
|
||||
videoSources
|
||||
.filter { it is IVideoUrlSource }
|
||||
.filter { it.isDownloadable() }
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||
selectedVideo = it as IVideoUrlSource;
|
||||
@@ -88,14 +94,14 @@ class UISlideOverlays {
|
||||
));
|
||||
|
||||
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(),
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
|
||||
|
||||
|
||||
audioSources?.let { audioSources ->
|
||||
items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources
|
||||
.filter { it is IAudioUrlSource }
|
||||
.filter { VideoHelper.isDownloadable(it) }
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it as IAudioUrlSource;
|
||||
@@ -111,24 +117,27 @@ class UISlideOverlays {
|
||||
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,
|
||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||
}
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||
if (selectedSubtitle == it) {
|
||||
selectedSubtitle = null;
|
||||
menu?.selectOption(subtitleSources, null);
|
||||
} else {
|
||||
selectedSubtitle = it;
|
||||
menu?.selectOption(subtitleSources, it);
|
||||
}
|
||||
}, false);
|
||||
}));
|
||||
//ContentResolver is required for subtitles..
|
||||
if(contentResolver != null) {
|
||||
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||
if (selectedSubtitle == it) {
|
||||
selectedSubtitle = null;
|
||||
menu?.selectOption(subtitleSources, null);
|
||||
} else {
|
||||
selectedSubtitle = it;
|
||||
menu?.selectOption(subtitleSources, it);
|
||||
}
|
||||
}, false);
|
||||
}));
|
||||
}
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items);
|
||||
|
||||
@@ -153,29 +162,12 @@ class UISlideOverlays {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val subtitleUri = subtitleToDownload.getSubtitlesURI();
|
||||
if (subtitleUri != null) {
|
||||
var subtitles: String? = null;
|
||||
if ("file" == subtitleUri.scheme) {
|
||||
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");
|
||||
}
|
||||
//TODO: Remove uri dependency, should be able to work with raw aswell?
|
||||
if (subtitleUri != null && contentResolver != null) {
|
||||
val subtitlesRaw = StateDownloads.instance.downloadSubtitles(subtitleToDownload, contentResolver);
|
||||
|
||||
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 {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -191,10 +183,41 @@ class UISlideOverlays {
|
||||
};
|
||||
return menu.apply { show() };
|
||||
}
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup) {
|
||||
showUnknownVideoDownload("Video", container) { px, bitrate ->
|
||||
StateDownloads.instance.download(video, px, bitrate)
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
|
||||
val handleUnknownDownload: ()->Unit = {
|
||||
showUnknownVideoDownload("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("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("Failed to fetch details for download");
|
||||
handleUnknownDownload();
|
||||
loader.hide(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else handleUnknownDownload();
|
||||
}
|
||||
}
|
||||
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
|
||||
showUnknownVideoDownload("Video", container) { px, bitrate ->
|
||||
@@ -269,6 +292,18 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
|
||||
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, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
|
||||
@@ -291,7 +326,7 @@ class UISlideOverlays {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
|
||||
{ showDownloadVideoOverlay(video, container); }, false)
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false)
|
||||
))
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, "Add To", "addto",
|
||||
@@ -344,7 +379,7 @@ class UISlideOverlays {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later",
|
||||
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
|
||||
{ showDownloadVideoOverlay(video, container); }, false))
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false))
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.icu.util.Output
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.os.OperationCanceledException
|
||||
@@ -15,6 +16,7 @@ import android.view.WindowInsetsController
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
||||
@@ -56,7 +58,7 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
|
||||
|
||||
fun warnIfMainThread(context: String) {
|
||||
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() {
|
||||
@@ -75,6 +77,16 @@ fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPool
|
||||
|
||||
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 {
|
||||
try {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.i(TAG, "onResume")
|
||||
Logger.v(TAG, "onResume")
|
||||
|
||||
val curOrientation = _orientationManager.orientation;
|
||||
|
||||
@@ -408,13 +408,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
val videoToOpen = StateSaved.instance.videoToOpen;
|
||||
|
||||
if (_wasStopped) {
|
||||
Logger.i(TAG, "_wasStopped is true");
|
||||
Logger.i(TAG, "set _wasStopped = false");
|
||||
_wasStopped = false;
|
||||
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
|
||||
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
@@ -427,13 +424,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Logger.i(TAG, "onPause")
|
||||
Logger.v(TAG, "onPause")
|
||||
_isVisible = false;
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Logger.i(TAG, "_wasStopped = true");
|
||||
Logger.v(TAG, "_wasStopped = true");
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
@@ -610,6 +607,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
val name = when(type) {
|
||||
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
|
||||
else -> type
|
||||
@@ -722,22 +720,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
_fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "onRestart5");
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
|
||||
|
||||
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);
|
||||
Logger.i(TAG, "onPictureInPictureModeChanged Ready");
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy();
|
||||
Logger.i(TAG, "onDestroy")
|
||||
Logger.v(TAG, "onDestroy")
|
||||
|
||||
_orientationManager.disable();
|
||||
|
||||
@@ -899,7 +895,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 requestCode: Int? = -1;
|
||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
|
||||
@@ -10,7 +10,10 @@ import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.ReadOnlyTextField
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -18,6 +21,7 @@ import com.google.android.material.button.MaterialButton
|
||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
private lateinit var _loader: Loader;
|
||||
|
||||
private lateinit var _devSets: LinearLayout;
|
||||
private lateinit var _buttonDev: MaterialButton;
|
||||
@@ -33,9 +37,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_buttonDev = findViewById(R.id.button_dev);
|
||||
_devSets = findViewById(R.id.dev_settings);
|
||||
_loader = findViewById(R.id.loader);
|
||||
|
||||
_form.fromObject(Settings.instance);
|
||||
_form.onChanged.subscribe { field, value ->
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
};
|
||||
@@ -59,6 +64,15 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
}
|
||||
};
|
||||
_lastActivity = this;
|
||||
|
||||
reloadSettings();
|
||||
}
|
||||
|
||||
fun reloadSettings() {
|
||||
_loader.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loader.stop();
|
||||
};
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -63,7 +63,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
}.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
|
||||
fun stop() {
|
||||
|
||||
@@ -94,7 +94,10 @@ class LiveChatManager {
|
||||
if(_pager is JSLiveEventPager)
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -11,12 +11,14 @@ class PlatformClientPool {
|
||||
private val _parent: JSClient;
|
||||
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
||||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null) {
|
||||
_poolName = name;
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
@@ -47,7 +49,7 @@ class PlatformClientPool {
|
||||
_poolCounter++;
|
||||
reserved = _pool.keys.find { !it.isBusy };
|
||||
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?.initialize();
|
||||
_pool[reserved!!] = _poolCounter;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.futo.platformplayer.api.media
|
||||
|
||||
class PlatformMultiClientPool {
|
||||
private val _name: String;
|
||||
private val _maxCap: Int;
|
||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
|
||||
private var _isFake = false;
|
||||
|
||||
constructor(maxCap: Int = -1) {
|
||||
constructor(name: String, maxCap: Int = -1) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
@@ -17,7 +19,7 @@ class PlatformMultiClientPool {
|
||||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
||||
this.onDead.subscribe { client, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
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 id: PlatformID = PlatformID("", null, pluginId);
|
||||
override val name: String = "";
|
||||
@@ -12,4 +12,5 @@ class PlatformContentPlaceholder(pluginId: String): IPlatformContent {
|
||||
override val shareUrl: String = "";
|
||||
override val datetime: OffsetDateTime? = null;
|
||||
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null);
|
||||
val error: Throwable? = exception
|
||||
}
|
||||
@@ -15,29 +15,36 @@ class DevJSClient : JSClient {
|
||||
|
||||
private val _devScript: String;
|
||||
private var _auth: SourceAuth? = null;
|
||||
private var _captcha: SourceCaptchaData? = null;
|
||||
|
||||
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;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
}
|
||||
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;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
}
|
||||
|
||||
fun setCaptcha(captcha: SourceCaptchaData? = null) {
|
||||
_captcha = captcha;
|
||||
}
|
||||
fun setAuth(auth: SourceAuth? = null) {
|
||||
_auth = auth;
|
||||
}
|
||||
fun recreate(context: Context): DevJSClient {
|
||||
return DevJSClient(context, config, _devScript, _auth, devID);
|
||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
|
||||
}
|
||||
|
||||
override fun getCopy(): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID);
|
||||
return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
|
||||
@@ -25,7 +25,11 @@ import com.futo.platformplayer.api.media.platforms.js.internal.*
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.*
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
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.ScriptValidationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -59,6 +63,7 @@ open class JSClient : IPlatformClient {
|
||||
private var _enabled: Boolean = false;
|
||||
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
private val _injectedSaveState: String?;
|
||||
|
||||
@@ -85,6 +90,7 @@ open class JSClient : IPlatformClient {
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
|
||||
val onDisabled = Event1<JSClient>();
|
||||
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
|
||||
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
|
||||
this._context = context;
|
||||
@@ -93,10 +99,11 @@ open class JSClient : IPlatformClient {
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
_auth = descriptor.getAuth();
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_client = JSHttpClient(this);
|
||||
_clientAuth = JSHttpClient(this, _auth);
|
||||
_client = JSHttpClient(this, null, _captcha);
|
||||
_clientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
@@ -108,6 +115,11 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
else
|
||||
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) {
|
||||
this._context = context;
|
||||
@@ -116,15 +128,21 @@ open class JSClient : IPlatformClient {
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
_auth = descriptor.getAuth();
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_client = JSHttpClient(this);
|
||||
_clientAuth = JSHttpClient(this, _auth);
|
||||
_client = JSHttpClient(this, null, _captcha);
|
||||
_clientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
_plugin.withScript(script);
|
||||
_script = script;
|
||||
|
||||
_plugin.onScriptException.subscribe {
|
||||
if(it is ScriptCaptchaRequiredException)
|
||||
onCaptchaException.emit(this, it);
|
||||
};
|
||||
}
|
||||
|
||||
open fun getCopy(): JSClient {
|
||||
@@ -561,11 +579,13 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
private fun announcePluginUnhandledException(method: String, ex: Throwable) {
|
||||
if(ex is PluginEngineException)
|
||||
return;
|
||||
try {
|
||||
StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}",
|
||||
"Plugin ${config.name} encountered an error in [${method}]",
|
||||
"${ex.message}\nPlease contact the plugin developer",
|
||||
AnnouncementType.RECURRING,
|
||||
AnnouncementType.SESSION_RECURRING,
|
||||
OffsetDateTime.now());
|
||||
}
|
||||
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())
|
||||
}
|
||||
+12
@@ -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,6 +35,7 @@ class SourcePluginConfig(
|
||||
|
||||
val settings: List<Setting> = listOf(),
|
||||
|
||||
var captcha: SourcePluginCaptchaConfig? = null,
|
||||
val authentication: SourcePluginAuthConfig? = null,
|
||||
var sourceUrl: String? = null,
|
||||
val constants: HashMap<String, String> = hashMapOf(),
|
||||
|
||||
+16
-3
@@ -13,22 +13,28 @@ class SourcePluginDescriptor {
|
||||
|
||||
var appSettings: AppPluginSettings = AppPluginSettings();
|
||||
|
||||
var authEncrypted: String?
|
||||
var authEncrypted: String? = null
|
||||
private set;
|
||||
var captchaEncrypted: String? = null
|
||||
private set;
|
||||
|
||||
val flags: List<String>;
|
||||
|
||||
@kotlinx.serialization.Transient
|
||||
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.authEncrypted = authEncrypted;
|
||||
this.captchaEncrypted = captchaEncrypted;
|
||||
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.authEncrypted = authEncrypted;
|
||||
this.captchaEncrypted = captchaEncrypted;
|
||||
this.flags = flags;
|
||||
}
|
||||
|
||||
@@ -41,6 +47,13 @@ class SourcePluginDescriptor {
|
||||
return map;
|
||||
}
|
||||
|
||||
fun updateCaptcha(captcha: SourceCaptchaData?) {
|
||||
captchaEncrypted = captcha?.toEncrypted();
|
||||
onCaptchaChanged.emit();
|
||||
}
|
||||
fun getCaptchaData(): SourceCaptchaData? {
|
||||
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
|
||||
}
|
||||
|
||||
fun updateAuth(str: SourceAuth?) {
|
||||
authEncrypted = str?.toEncrypted();
|
||||
|
||||
+32
-24
@@ -5,65 +5,72 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
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.SourceCaptchaData
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
var doUpdateCookies: Boolean = true;
|
||||
var doApplyCookies: Boolean = true;
|
||||
var doAllowNewCookies: Boolean = true;
|
||||
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;
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
|
||||
_currentCookieMap = hashMapOf();
|
||||
if(!auth?.cookieMap.isNullOrEmpty()) {
|
||||
_currentCookieMap = hashMapOf();
|
||||
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!!)
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun clone(): ManagedHttpClient {
|
||||
val newClient = JSHttpClient(_jsClient, _auth);
|
||||
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
|
||||
null;
|
||||
hashMapOf();
|
||||
return newClient;
|
||||
}
|
||||
|
||||
override fun beforeRequest(request: Request) {
|
||||
val domain = Uri.parse(request.url).host!!.lowercase();
|
||||
|
||||
val auth = _auth;
|
||||
if (auth != null) {
|
||||
val domain = Uri.parse(request.url).host!!.lowercase();
|
||||
|
||||
//TODO: Possibly add doApplyHeaders
|
||||
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
|
||||
request.headers[header.key] = header.value;
|
||||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (!_currentCookieMap.isNullOrEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap!!) {
|
||||
for(cookie in _currentCookieMap!!
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
if(doApplyCookies) {
|
||||
if (!_currentCookieMap.isNullOrEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap!!) {
|
||||
for(cookie in _currentCookieMap!!
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
request.headers["Cookie"] = cookieString;
|
||||
}
|
||||
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
request.headers["Cookie"] = cookieString;
|
||||
}
|
||||
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +87,7 @@ class JSHttpClient : ManagedHttpClient {
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in resp.headers) {
|
||||
if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") {
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.key.lowercase() == "set-cookie") {
|
||||
val newCookies = cookieStringToMap(header.value);
|
||||
for (cookie in newCookies) {
|
||||
val endIndex = cookie.value.indexOf(";");
|
||||
@@ -155,4 +162,5 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
Logger.i("Testing", code);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,7 +42,6 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
||||
|
||||
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();
|
||||
Logger.i("JSContent", "name=$name");
|
||||
author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName));
|
||||
|
||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||
|
||||
+1
-4
@@ -9,7 +9,7 @@ import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
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 codec: String = "HLS";
|
||||
override val name : String;
|
||||
@@ -31,9 +31,6 @@ class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSou
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
|
||||
override fun getAudioUrl(): String {
|
||||
return url;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) };
|
||||
|
||||
+1
-5
@@ -7,7 +7,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource {
|
||||
class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
override val width : Int = 0;
|
||||
override val height : Int = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
override fun getVideoUrl(): String {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 {
|
||||
_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;
|
||||
return _currentResults;
|
||||
|
||||
+16
-3
@@ -1,5 +1,6 @@
|
||||
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.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -37,8 +38,12 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
|
||||
synchronized(_pending) {
|
||||
_pending.remove(pendingPager);
|
||||
}
|
||||
if(error != null)
|
||||
if(error != null) {
|
||||
onPagerError.emit(error);
|
||||
val replacing = _placeHolderPagersPaired[pendingPager];
|
||||
if(replacing != null)
|
||||
updatePager(null, replacing, error);
|
||||
}
|
||||
else
|
||||
updatePager(pendingPager.getCompleted());
|
||||
}
|
||||
@@ -60,9 +65,17 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
|
||||
override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() };
|
||||
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
|
||||
|
||||
private fun updatePager(pagerToAdd: IPager<T>?) {
|
||||
if(pagerToAdd == null)
|
||||
private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
|
||||
if(pagerToAdd == null) {
|
||||
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
|
||||
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
|
||||
_currentPager = PlaceholderPager(5) {
|
||||
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
|
||||
} as IPager<T>;
|
||||
onPagerChanged.emit(_currentPager);
|
||||
}
|
||||
return;
|
||||
}
|
||||
synchronized(_pagersReusable) {
|
||||
Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
|
||||
_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.
|
||||
*/
|
||||
class PlaceholderPager : IPager<IPlatformContent> {
|
||||
private val _creator: ()->IPlatformContent;
|
||||
val placeholderFactory: ()->IPlatformContent;
|
||||
private val _pageSize: Int;
|
||||
|
||||
constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) {
|
||||
_creator = placeholderCreator;
|
||||
placeholderFactory = placeholderCreator;
|
||||
_pageSize = pageSize;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class PlaceholderPager : IPager<IPlatformContent> {
|
||||
override fun getResults(): List<IPlatformContent> {
|
||||
val pages = ArrayList<IPlatformContent>();
|
||||
for(item in 1.._pageSize)
|
||||
pages.add(_creator());
|
||||
pages.add(placeholderFactory());
|
||||
return pages;
|
||||
}
|
||||
override fun hasMorePages(): Boolean = true;
|
||||
|
||||
@@ -111,7 +111,7 @@ class ChannelContentCache {
|
||||
init {
|
||||
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) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
|
||||
@@ -64,7 +64,7 @@ class StateCasting {
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "ChromeCast service resolved: " + event.info);
|
||||
Logger.v(TAG, "ChromeCast service resolved: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
|
||||
protected val _conditionalListeners = mutableListOf<TaggedHandler<ConditionalHandler>>();
|
||||
protected val _listeners = mutableListOf<TaggedHandler<Handler>>();
|
||||
|
||||
fun hasListeners(): Boolean =
|
||||
synchronized(_listeners){_listeners.isNotEmpty()} ||
|
||||
synchronized(_conditionalListeners){_conditionalListeners.isNotEmpty()};
|
||||
|
||||
fun subscribeConditional(listener: ConditionalHandler) {
|
||||
synchronized(_conditionalListeners) {
|
||||
_conditionalListeners.add(TaggedHandler(listener));
|
||||
@@ -65,10 +69,7 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
|
||||
|
||||
class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
||||
fun emit() : Boolean {
|
||||
var handled: Boolean;
|
||||
synchronized(_listeners) {
|
||||
handled = _listeners.isNotEmpty();
|
||||
}
|
||||
var handled = false;
|
||||
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
@@ -76,6 +77,7 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
handler.handler.invoke();
|
||||
}
|
||||
@@ -85,17 +87,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
||||
}
|
||||
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
||||
fun emit(value : T1): Boolean {
|
||||
var handled: Boolean;
|
||||
synchronized(_listeners) {
|
||||
handled = _listeners.isNotEmpty();
|
||||
}
|
||||
|
||||
var handled = false;
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
handled = handled || conditional.handler.invoke(value);
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
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)>() {
|
||||
fun emit(value1 : T1, value2 : T2): Boolean {
|
||||
var handled: Boolean;
|
||||
synchronized(_listeners) {
|
||||
handled = _listeners.isNotEmpty();
|
||||
}
|
||||
var handled = false;
|
||||
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
@@ -116,6 +112,7 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
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)>() {
|
||||
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
|
||||
var handled: Boolean;
|
||||
synchronized(_listeners) {
|
||||
handled = _listeners.isNotEmpty();
|
||||
}
|
||||
var handled = false;
|
||||
|
||||
synchronized(_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) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
handler.handler.invoke(value1, value2, value3);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class TaskHandler<TParameter, TResult> {
|
||||
fun run(parameter: TParameter) {
|
||||
val id = ++_idGenerator;
|
||||
|
||||
var handled = false;
|
||||
_scope().launch(_dispatcher) {
|
||||
if (id != _idGenerator)
|
||||
return@launch;
|
||||
@@ -67,35 +68,53 @@ class TaskHandler<TParameter, TResult> {
|
||||
return@launch;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (id != _idGenerator)
|
||||
if (id != _idGenerator) {
|
||||
handled = true;
|
||||
return@withContext;
|
||||
}
|
||||
|
||||
try {
|
||||
onSuccess.emit(result);
|
||||
handled = true;
|
||||
}
|
||||
catch (e: Throwable) {
|
||||
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
|
||||
onError.emit(e, parameter);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: Throwable) {
|
||||
Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message);
|
||||
if (id != _idGenerator)
|
||||
if (id != _idGenerator) {
|
||||
handled = true;
|
||||
return@launch;
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
handled = true;
|
||||
if (id != _idGenerator)
|
||||
return@withContext;
|
||||
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} 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
|
||||
|
||||
@@ -416,7 +416,7 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
val resp = _client.get(body.url!!, body.headers);
|
||||
|
||||
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"));
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -58,13 +59,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
||||
}
|
||||
clearFocus();
|
||||
dismiss();
|
||||
|
||||
Logger.i(TAG, "Set AutoBackupPassword");
|
||||
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
|
||||
UIDialogs.toast(context, "AutoBackup enabled");
|
||||
|
||||
try {
|
||||
StateBackup.startAutomaticBackup(true);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
val processHandle = StatePolycentric.instance.processHandle!!
|
||||
val eventPointer = processHandle.post(comment, null, ref)
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers()
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class PlaylistDownloadDescriptor(
|
||||
val id: String,
|
||||
val targetPxCount: 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.SerializedPlatformVideoDetails
|
||||
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.VideoHelper
|
||||
import com.futo.platformplayer.isDownloadable
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -27,7 +31,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
@@ -147,27 +150,37 @@ class VideoDownload {
|
||||
if(original !is IPlatformVideoDetails)
|
||||
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());
|
||||
if(videoSource == null && targetPixelCount != null) {
|
||||
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
|
||||
?: throw IllegalStateException("Could not find a valid video source for video");
|
||||
if(vsource is IVideoUrlSource)
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource);
|
||||
else
|
||||
throw IllegalStateException("Download video source is not a url source");
|
||||
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
||||
if(vsource != null) {
|
||||
if (vsource is IVideoUrlSource)
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource);
|
||||
else
|
||||
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
||||
}
|
||||
}
|
||||
|
||||
if(audioSource == null && targetBitrate != null) {
|
||||
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
|
||||
?: 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)
|
||||
audioSource = null;
|
||||
else if(asource is IAudioUrlSource)
|
||||
audioSource = AudioUrlSource.fromUrlSource(asource);
|
||||
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 {
|
||||
@@ -358,7 +371,7 @@ class VideoDownload {
|
||||
}
|
||||
|
||||
if (isCancelled)
|
||||
throw IllegalStateException("Cancelled");
|
||||
throw CancellationException("Cancelled");
|
||||
} while (read > 0);
|
||||
|
||||
lastSpeed = 0;
|
||||
@@ -410,7 +423,7 @@ class VideoDownload {
|
||||
}
|
||||
|
||||
if(isCancelled)
|
||||
throw IllegalStateException("Cancelled");
|
||||
throw CancellationException("Cancelled", null);
|
||||
}
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.futo.platformplayer.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import com.caoccao.javet.exceptions.JavetCompilationException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
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.packages.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class V8Plugin {
|
||||
val config: IV8PluginConfig;
|
||||
@@ -31,14 +28,31 @@ class V8Plugin {
|
||||
val httpClient: ManagedHttpClient get() = _client;
|
||||
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
|
||||
|
||||
private val _runtimeLock = Object();
|
||||
var _runtime : V8Runtime? = null;
|
||||
|
||||
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
|
||||
private val _depsPackages : MutableList<V8Package> = mutableListOf();
|
||||
private var _script : String? = null;
|
||||
|
||||
var isStopped = true;
|
||||
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()) {
|
||||
this._client = client;
|
||||
this._clientAuth = clientAuth;
|
||||
@@ -81,7 +95,7 @@ class V8Plugin {
|
||||
|
||||
fun start() {
|
||||
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
|
||||
synchronized(this) {
|
||||
synchronized(_runtimeLock) {
|
||||
if (_runtime != null)
|
||||
return;
|
||||
|
||||
@@ -121,19 +135,25 @@ class V8Plugin {
|
||||
catchScriptErrors("Plugin[${config.name}]") {
|
||||
it.getExecutor(script).executeVoid()
|
||||
};
|
||||
isStopped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fun stop(){
|
||||
Logger.i(TAG, "Stopping plugin [${config.name}]");
|
||||
synchronized(this) {
|
||||
_runtime?.let {
|
||||
_runtime = null;
|
||||
if(!it.isClosed && !it.isDead)
|
||||
it.close();
|
||||
};
|
||||
isStopped = true;
|
||||
whenNotBusy {
|
||||
synchronized(_runtimeLock) {
|
||||
isStopped = true;
|
||||
_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 {
|
||||
@@ -141,14 +161,53 @@ class V8Plugin {
|
||||
}
|
||||
fun <T : V8Value> executeTyped(js: String) : T {
|
||||
warnIfMainThread("V8Plugin.executeTyped");
|
||||
if(isStopped)
|
||||
throw PluginEngineStoppedException(config, "Instance is stopped", js);
|
||||
|
||||
synchronized(_busyCounterLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
|
||||
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 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 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 {
|
||||
//TODO: Auto get all package types?
|
||||
return when(packageName) {
|
||||
@@ -160,7 +219,13 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -185,7 +250,7 @@ class V8Plugin {
|
||||
if(result is V8ValueObject) {
|
||||
val type = result.getString("plugin_type");
|
||||
if(type != null && type.endsWith("Exception"))
|
||||
Companion.throwExceptionFromV8(
|
||||
throwExceptionFromV8(
|
||||
config,
|
||||
result.getOrThrow(config, "plugin_type", "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);
|
||||
}
|
||||
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(
|
||||
config,
|
||||
executeEx.scriptingError.context["plugin_type"].toString(),
|
||||
(exMessage ?: ""),
|
||||
pluginType,
|
||||
(extractJSExceptionMessage(executeEx) ?: ""),
|
||||
executeEx,
|
||||
executeEx.scriptingError?.stack,
|
||||
codeStripped
|
||||
);
|
||||
|
||||
throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
throw ex;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
+11
@@ -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) {
|
||||
|
||||
}
|
||||
+18
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,12 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
override fun toV8(runtime: V8Runtime): V8Value? {
|
||||
val obj = runtime.createV8ValueObject();
|
||||
obj.set("url", url);
|
||||
obj.set("code", code);
|
||||
obj.set("body", body);
|
||||
obj.set("headers", headers);
|
||||
@@ -227,7 +228,7 @@ class PackageHttp: V8Package {
|
||||
val resp = client.requestMethod(method, url, headers);
|
||||
val responseBody = resp.body?.string();
|
||||
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 responseBody = resp.body?.string();
|
||||
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 responseBody = resp.body?.string();
|
||||
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 responseBody = resp.body?.string();
|
||||
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
|
||||
catch(ex: SocketTimeoutException) {
|
||||
return BridgeHttpResponse(408, null);
|
||||
return BridgeHttpResponse("", 408, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,7 +462,7 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
//Forward timeouts
|
||||
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;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -29,6 +29,7 @@ import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
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.PolycentricProfile
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
@@ -76,7 +77,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
}).success {
|
||||
setLoading(false);
|
||||
setPager(it);
|
||||
}.exception<Throwable> {
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load initial videos.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
|
||||
};
|
||||
|
||||
+3
-1
@@ -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.constructs.Event1
|
||||
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.PolycentricProfile
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -52,7 +53,8 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
|
||||
_authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail));
|
||||
adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1));
|
||||
loadNext();
|
||||
}.exceptionWithParameter<Throwable> { ex, para ->
|
||||
}.exception<ScriptCaptchaRequiredException> { }
|
||||
.exceptionWithParameter<Throwable> { ex, para ->
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
|
||||
UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false)
|
||||
loadNext();
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
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.views.FeedStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -86,7 +87,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
}
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
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.views.FeedStyle
|
||||
|
||||
@@ -56,6 +57,7 @@ class CreatorSearchResultsFragment : MainFragment() {
|
||||
constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) })
|
||||
.success { loadedResult(it); }
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
|
||||
+15
-3
@@ -8,21 +8,27 @@ import android.view.ViewGroup
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.CaptchaActivity
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
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.internal.JSHttpClient
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
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.ScriptImplementationException
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.CaptchaWebViewClient
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
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.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
@@ -92,6 +98,7 @@ class HomeFragment : MainFragment() {
|
||||
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<ScriptExecutionException> {
|
||||
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
|
||||
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0,
|
||||
@@ -100,17 +107,20 @@ class HomeFragment : MainFragment() {
|
||||
);
|
||||
}
|
||||
.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.Action("Ignore", {}),
|
||||
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
.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, {
|
||||
loadResults()
|
||||
});
|
||||
}) {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +141,8 @@ class HomeFragment : MainFragment() {
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
finishRefreshLayoutLoader();
|
||||
}
|
||||
|
||||
override fun reload() {
|
||||
|
||||
+1
-1
@@ -355,7 +355,7 @@ class PostDetailFragment : MainFragment {
|
||||
processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+16
-2
@@ -28,6 +28,7 @@ import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.subscriptions.SubscriptionBar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -129,6 +130,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!StateSubscriptions.instance.isGlobalUpdating) {
|
||||
finishRefreshLayoutLoader();
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
@@ -168,7 +173,12 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.success { loadedResult(it); }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
if(it !is CancellationException)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
private fun initializeToolbarContent() {
|
||||
@@ -251,7 +261,11 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to finish loading", e)
|
||||
}
|
||||
}
|
||||
}/*.invokeOnCompletion { //Commented for now, because it doesn't fix the bug it was intended to fix, but might want it later anyway
|
||||
if(it is CancellationException) {
|
||||
setLoading(false);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
private fun handleExceptions(exs: List<Throwable>) {
|
||||
|
||||
+7
-7
@@ -306,12 +306,12 @@ class VideoDetailFragment : MainFragment {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.i(TAG, "onResume");
|
||||
Logger.v(TAG, "onResume");
|
||||
_isActive = true;
|
||||
_leavingPiP = false;
|
||||
|
||||
_viewDetail?.let {
|
||||
Logger.i(TAG, "onResume preventPictureInPicture=false");
|
||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||
it.preventPictureInPicture = false;
|
||||
|
||||
if (state != State.CLOSED) {
|
||||
@@ -326,7 +326,7 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Logger.i(TAG, "onPause");
|
||||
Logger.v(TAG, "onPause");
|
||||
_isActive = false;
|
||||
|
||||
if(!isInPictureInPicture && state != State.CLOSED)
|
||||
@@ -334,7 +334,7 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Logger.i(TAG, "onStop");
|
||||
Logger.v(TAG, "onStop");
|
||||
|
||||
stopIfRequired();
|
||||
super.onStop();
|
||||
@@ -352,7 +352,7 @@ class VideoDetailFragment : MainFragment {
|
||||
shouldStop = false;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "shouldStop: $shouldStop");
|
||||
Logger.v(TAG, "shouldStop: $shouldStop");
|
||||
if(shouldStop) {
|
||||
_viewDetail?.let {
|
||||
val v = it.video ?: return@let;
|
||||
@@ -361,13 +361,13 @@ class VideoDetailFragment : MainFragment {
|
||||
|
||||
_viewDetail?.onStop();
|
||||
StateCasting.instance.onStop();
|
||||
Logger.i(TAG, "called onStop() shouldStop: $shouldStop");
|
||||
Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
Logger.i(TAG, "onDestroyMainView");
|
||||
Logger.v(TAG, "onDestroyMainView");
|
||||
_viewDetail?.let {
|
||||
_viewDetail = null;
|
||||
it.onDestroy();
|
||||
|
||||
+78
-12
@@ -100,6 +100,7 @@ import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToLong
|
||||
import kotlin.streams.toList
|
||||
|
||||
@@ -232,9 +233,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
private val DP_5 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||
private val DP_2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics);
|
||||
|
||||
private var _retryJob: Job? = null;
|
||||
private var _retryCount = 0;
|
||||
private val _retryIntervals: Array<Long> = arrayOf(1, 2, 4, 8, 16, 32);
|
||||
//TODO: Determine better behavior, waiting 60 seconds for an error that is guaranteed to happen is a bit much. (Needed? If so, maybe need special UI for retrying)
|
||||
private val _retryIntervals: Array<Long> = arrayOf(1, 1);//2, 4, 8, 16, 32);
|
||||
|
||||
private var _liveTryJob: Job? = null;
|
||||
private val _liveStreamCheckInterval = listOf(
|
||||
Pair(-10 * 60, 5 * 60), //around 10 minutes, try every 5 minute
|
||||
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
|
||||
Pair(0, 10) //around live, try every 10 seconds
|
||||
);
|
||||
|
||||
|
||||
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.fragview_video_detail, this);
|
||||
@@ -491,7 +502,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
|
||||
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
|
||||
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
|
||||
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo() };
|
||||
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo(true) };
|
||||
MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() };
|
||||
MediaControlReceiver.onCloseReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||
@@ -597,7 +608,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) {
|
||||
video?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(context.contentResolver, it, _overlayContainer);
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||
};
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) {
|
||||
@@ -660,7 +671,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
//Lifecycle
|
||||
fun onResume() {
|
||||
Logger.i(TAG, "onResume");
|
||||
Logger.v(TAG, "onResume");
|
||||
_onPauseCalled = false;
|
||||
|
||||
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
||||
@@ -694,7 +705,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.updateRotateLock();
|
||||
}
|
||||
fun onPause() {
|
||||
Logger.i(TAG, "onPause");
|
||||
Logger.v(TAG, "onPause");
|
||||
|
||||
_onPauseCalled = true;
|
||||
_taskLoadVideo.cancel();
|
||||
@@ -722,6 +733,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_overlay_quality_selector?.hide();
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_taskLoadVideo.cancel();
|
||||
handleStop();
|
||||
_didStop = true;
|
||||
@@ -808,6 +821,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_retryCount = 0;
|
||||
fetchVideo();
|
||||
|
||||
@@ -897,6 +912,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_retryCount = 0;
|
||||
fetchVideo();
|
||||
}
|
||||
@@ -1034,7 +1051,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
@@ -1130,6 +1147,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(video.isLive && video.live != null) {
|
||||
loadLiveChat(video);
|
||||
}
|
||||
if(video.isLive && video.live == null && !video.video.videoSources.any())
|
||||
startLiveTry(video);
|
||||
|
||||
updateMoreButtons();
|
||||
}
|
||||
@@ -1259,7 +1278,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
//If LiveStream, set to end
|
||||
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||
if (video?.isLive == true) {
|
||||
_player.seekToEnd(5000);
|
||||
_player.seekToEnd(6000);
|
||||
}
|
||||
|
||||
val videoTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
@@ -1344,9 +1363,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fun nextVideo(): Boolean {
|
||||
fun nextVideo(forceLoop: Boolean = false): Boolean {
|
||||
Logger.i(TAG, "nextVideo")
|
||||
val next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
|
||||
var next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
|
||||
if(next == null && forceLoop)
|
||||
next = StatePlayer.instance.restartQueue();
|
||||
if(next != null) {
|
||||
setVideoOverview(next);
|
||||
return true;
|
||||
@@ -1961,7 +1982,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
||||
Logger.i(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})");
|
||||
Logger.v(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})");
|
||||
isOverlayed?.let{ _cast.setProgressBarOverlayed(it) };
|
||||
|
||||
if(isOverlayed == null) {
|
||||
@@ -2080,6 +2101,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryCount = 0;
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo);
|
||||
}
|
||||
}
|
||||
@@ -2090,6 +2113,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryCount = 0;
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo);
|
||||
}
|
||||
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
|
||||
@@ -2107,14 +2132,16 @@ class VideoDetailView : ConstraintLayout {
|
||||
Log.i(TAG, "handleErrorOrCall _retryCount=$_retryCount, starting retry job");
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = StateApp.instance.scopeGetter().launch(Dispatchers.Main) {
|
||||
_retryJob = StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
delay(_retryIntervals[_retryCount++] * 1000);
|
||||
fetchVideo();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to fetch video.", e)
|
||||
Logger.e(TAG, "Failed to retry fetch video.", e)
|
||||
}
|
||||
}
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
} else if (isConnected && nextVideo()) {
|
||||
Log.i(TAG, "handleErrorOrCall retries failed, is connected, skipped to next video");
|
||||
} else {
|
||||
@@ -2123,6 +2150,45 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveTry(liveTryVideo: IPlatformVideoDetails) {
|
||||
val datetime = liveTryVideo.datetime ?: return;
|
||||
val diffSeconds = datetime.getNowDiffSeconds();
|
||||
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main){
|
||||
UIDialogs.toast(context, "Not yet available, retrying in ${toWait}s");
|
||||
}
|
||||
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
delay(toWait * 1000);
|
||||
val videoDetail = StatePlatform.instance.getContentDetails(liveTryVideo.url, true).await();
|
||||
if(videoDetail !is IPlatformVideoDetails)
|
||||
throw IllegalStateException("Expected media content, found ${video?.contentType}");
|
||||
|
||||
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
|
||||
if(videoDetail.datetime!! > OffsetDateTime.now())
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}");
|
||||
}
|
||||
startLiveTry(liveTryVideo);
|
||||
}
|
||||
else
|
||||
withContext(Dispatchers.Main) {
|
||||
setVideoDetails(videoDetail);
|
||||
_liveTryJob = null;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to live try fetch video.", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to retry for live stream");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFragment(frag: VideoDetailFragment) {
|
||||
fragment = frag;
|
||||
fragment.onMinimize.subscribe {
|
||||
|
||||
@@ -4,7 +4,10 @@ import android.net.Uri
|
||||
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.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -17,6 +20,12 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
|
||||
class VideoHelper {
|
||||
companion object {
|
||||
|
||||
fun isDownloadable(detail: IPlatformVideoDetails) =
|
||||
(detail.video.videoSources.any { isDownloadable(it) }) ||
|
||||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
|
||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
|
||||
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
|
||||
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||
val targetVideo = if(desiredPixelCount > 0)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.futo.platformplayer.others
|
||||
|
||||
import android.webkit.*
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
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.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
class CaptchaWebViewClient : WebViewClient {
|
||||
val onCaptchaFinished = Event1<SourceCaptchaData?>();
|
||||
val onPageLoaded = Event2<WebView?, String?>()
|
||||
|
||||
private val _pluginConfig: SourcePluginConfig?;
|
||||
private val _captchaConfig: SourcePluginCaptchaConfig;
|
||||
|
||||
private var _didNotify = false;
|
||||
private val _extractor: WebViewRequirementExtractor;
|
||||
|
||||
constructor(config: SourcePluginConfig) : super() {
|
||||
_pluginConfig = config;
|
||||
_captchaConfig = config.captcha!!;
|
||||
_extractor = WebViewRequirementExtractor(
|
||||
config.allowUrls,
|
||||
null,
|
||||
null,
|
||||
config.captcha!!.cookiesToFind,
|
||||
config.captcha!!.completionUrl,
|
||||
config.captcha!!.cookiesExclOthers
|
||||
);
|
||||
Logger.i(TAG, "Captcha [${config.name}]" +
|
||||
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
|
||||
}
|
||||
constructor(captcha: SourcePluginCaptchaConfig) : super() {
|
||||
_pluginConfig = null;
|
||||
_captchaConfig = captcha;
|
||||
_extractor = WebViewRequirementExtractor(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
captcha.cookiesToFind,
|
||||
captcha.completionUrl,
|
||||
captcha.cookiesExclOthers
|
||||
);
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url);
|
||||
Logger.i(TAG, "onPageFinished url = ${url}")
|
||||
onPageLoaded.emit(view, url);
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||
if(request == null)
|
||||
return super.shouldInterceptRequest(view, request as WebResourceRequest?);
|
||||
|
||||
val extracted = _extractor.handleRequest(view, request);
|
||||
if(extracted != null && !_didNotify) {
|
||||
_didNotify = true;
|
||||
onCaptchaFinished.emit(SourceCaptchaData(
|
||||
extracted.cookies,
|
||||
extracted.headers
|
||||
));
|
||||
}
|
||||
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CaptchaWebViewClient";
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class LoginWebViewClient : WebViewClient {
|
||||
onPageLoaded.emit(view, url);
|
||||
}
|
||||
|
||||
//TODO: Use new WebViewRequirementExtractor when time to test extensively
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||
if(request == null)
|
||||
return super.shouldInterceptRequest(view, request as WebResourceRequest?);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.futo.platformplayer.others
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
|
||||
class WebViewRequirementExtractor {
|
||||
private val allowedUrls: List<String>;
|
||||
private val headersToFind: List<String>?;
|
||||
private val domainHeadersToFind: Map<String, List<String>>?;
|
||||
private val cookiesToFind: List<String>?;
|
||||
private val completionUrl: String?;
|
||||
|
||||
private val exclOtherCookies: Boolean;
|
||||
|
||||
|
||||
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
|
||||
private val cookiesFoundMap = hashMapOf<String, HashMap<String, String>>();
|
||||
private var urlFound = false;
|
||||
|
||||
|
||||
constructor(allowedUrls: List<String>?, headers: List<String>?, domainHeaders: Map<String, List<String>>?, cookies: List<String>?, url: String?, exclOtherCookies: Boolean = false) {
|
||||
this.allowedUrls = allowedUrls ?: listOf("everywhere");
|
||||
this.exclOtherCookies = exclOtherCookies;
|
||||
headersToFind = headers;
|
||||
domainHeadersToFind = domainHeaders;
|
||||
cookiesToFind = cookies;
|
||||
completionUrl = url;
|
||||
}
|
||||
|
||||
|
||||
fun handleRequest(view: WebView?, request: WebResourceRequest, logVerbose: Boolean = false): ExtractedData? {
|
||||
|
||||
val domain = request.url.host;
|
||||
val domainLower = request.url.host?.lowercase();
|
||||
if(completionUrl == null)
|
||||
urlFound = true;
|
||||
else urlFound = urlFound || request.url == Uri.parse(completionUrl);
|
||||
|
||||
//HEADERS
|
||||
if(domainLower != null) {
|
||||
val headersToFind = ((headersToFind?.map { Pair(it.lowercase(), domainLower) } ?: listOf()) +
|
||||
(domainHeadersToFind?.filter { domainLower.matchesDomain(it.key.lowercase())}
|
||||
?.flatMap { it.value.map { header -> Pair(header.lowercase(), it.key.lowercase()) } } ?: listOf()));
|
||||
|
||||
val foundHeaders = request.requestHeaders.filter { requestHeader -> headersToFind.any { it.first.equals(requestHeader.key, true)} &&
|
||||
(!requestHeader.key.equals("Authorization", ignoreCase = true) || requestHeader.value != "undefined") } //TODO: More universal fix (optional regex?)
|
||||
for(header in foundHeaders) {
|
||||
for(headerDomain in headersToFind.filter { it.first.equals(header.key, true) }) {
|
||||
if (!headersFoundMap.containsKey(headerDomain.second))
|
||||
headersFoundMap[headerDomain.second] = hashMapOf();
|
||||
headersFoundMap[headerDomain.second]!![header.key.lowercase()] = header.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//COOKIES
|
||||
//TODO: This is not an ideal solution, we want to intercept the response, but interception need to be rewritten to support that. Correct implementation commented underneath
|
||||
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
|
||||
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
|
||||
if(cookieString != null) {
|
||||
val domainParts = domain!!.split(".");
|
||||
val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||
cookiesToFind?.let { cookiesToFind ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
val cookieSplitIndex = cookieStr.indexOf("=");
|
||||
if(cookieSplitIndex <= 0) continue;
|
||||
val cookieKey = cookieStr.substring(0, cookieSplitIndex).trim();
|
||||
val cookieVal = cookieStr.substring(cookieSplitIndex + 1).trim();
|
||||
|
||||
if (exclOtherCookies && !cookiesToFind.contains(cookieKey))
|
||||
continue;
|
||||
|
||||
if (cookiesFoundMap.containsKey(cookieDomain))
|
||||
cookiesFoundMap[cookieDomain]!![cookieKey] = cookieVal;
|
||||
else
|
||||
cookiesFoundMap[cookieDomain] = hashMapOf(Pair(cookieKey, cookieVal));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
val headersFound = headersToFind?.map { it.lowercase() }?.all { reqHeader -> headersFoundMap.any { it.value.containsKey(reqHeader) } } ?: true
|
||||
val domainHeadersFound = domainHeadersToFind?.all {
|
||||
if(it.value.isEmpty())
|
||||
return@all true;
|
||||
if(!headersFoundMap.containsKey(it.key.lowercase()))
|
||||
return@all false;
|
||||
val foundDomainHeaders = headersFoundMap[it.key.lowercase()] ?: mapOf();
|
||||
return@all it.value.all { reqHeader -> foundDomainHeaders.containsKey(reqHeader.lowercase()) };
|
||||
} ?: true;
|
||||
val cookiesFound = cookiesToFind?.all { toFind -> cookiesFoundMap.any { it.value.containsKey(toFind) } } ?: true;
|
||||
|
||||
if(logVerbose) {
|
||||
val builder = StringBuilder();
|
||||
builder.appendLine("Request (method: ${request.method}, host: ${request.url.host}, url: ${request.url}, path: ${request.url.path}):");
|
||||
for (pair in request.requestHeaders) {
|
||||
builder.appendLine(" ${pair.key}: ${pair.value}");
|
||||
}
|
||||
builder.appendLine(" Cookies: ${cookiesFoundMap.values.sumOf { it.values.size }}");
|
||||
Logger.i(TAG, builder.toString());
|
||||
Logger.i(TAG, "Result (urlFound: $urlFound, headersFound: $headersFound, cookiesFound: $cookiesFound)");
|
||||
}
|
||||
|
||||
if (urlFound && headersFound && domainHeadersFound && cookiesFound)
|
||||
return ExtractedData(cookiesFoundMap, headersFoundMap);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
data class ExtractedData(
|
||||
val cookies: HashMap<String, HashMap<String, String>>,
|
||||
val headers: HashMap<String, HashMap<String, String>>
|
||||
);
|
||||
companion object {
|
||||
val TAG = "WebViewRequirementExtractor";
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Announcement
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
@@ -134,15 +136,21 @@ class DownloadService : Service() {
|
||||
Logger.w(TAG, "Video had no video or videodetail, removing download");
|
||||
StateDownloads.instance.removeDownload(currentVideo);
|
||||
}
|
||||
else if(ex is DownloadException && !ex.isRetryable) {
|
||||
Logger.w(TAG, "Video had exception that should not be retried");
|
||||
StateDownloads.instance.removeDownload(currentVideo);
|
||||
StateDownloads.instance.preventPlaylistDownload(currentVideo);
|
||||
}
|
||||
else
|
||||
Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex);
|
||||
currentVideo.error = ex.message;
|
||||
currentVideo.changeState(VideoDownload.State.ERROR);
|
||||
ignore.add(currentVideo);
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload",
|
||||
"Download failed",
|
||||
"Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download");
|
||||
if(ex !is CancellationException)
|
||||
StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload",
|
||||
"Download failed",
|
||||
"Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download");
|
||||
|
||||
//Give it a sec
|
||||
Thread.sleep(500);
|
||||
|
||||
@@ -50,7 +50,7 @@ class MediaPlaybackService : Service() {
|
||||
private var _focusRequest: AudioFocusRequest? = null;
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Logger.i(TAG, "onStartCommand");
|
||||
Logger.v(TAG, "onStartCommand");
|
||||
|
||||
|
||||
if(!FragmentedStorage.isInitialized) {
|
||||
@@ -91,43 +91,49 @@ class MediaPlaybackService : Service() {
|
||||
_mediaSession?.setCallback(object: MediaSessionCompat.Callback() {
|
||||
override fun onSeekTo(pos: Long) {
|
||||
super.onSeekTo(pos)
|
||||
Log.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||
Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||
MediaControlReceiver.onSeekToReceived.emit(pos);
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
super.onPlay();
|
||||
Log.i(TAG, "Media session callback onPlay()");
|
||||
Logger.i(TAG, "Media session callback onPlay()");
|
||||
MediaControlReceiver.onPlayReceived.emit();
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Log.i(TAG, "Media session callback onPause()");
|
||||
Logger.i(TAG, "Media session callback onPause()");
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop();
|
||||
Log.i(TAG, "Media session callback onStop()");
|
||||
Logger.i(TAG, "Media session callback onStop()");
|
||||
MediaControlReceiver.onCloseReceived.emit();
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious();
|
||||
Log.i(TAG, "Media session callback onSkipToPrevious()");
|
||||
Logger.i(TAG, "Media session callback onSkipToPrevious()");
|
||||
MediaControlReceiver.onPreviousReceived.emit();
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
Logger.i(TAG, "Media session callback onSkipToNext()");
|
||||
MediaControlReceiver.onNextReceived.emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
Logger.i(TAG, "onCreate called");
|
||||
Logger.v(TAG, "onCreate");
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.i(TAG, "onDestroy called");
|
||||
Logger.v(TAG, "onDestroy");
|
||||
_instance = null;
|
||||
MediaControlReceiver.onCloseReceived.emit();
|
||||
super.onDestroy();
|
||||
@@ -138,7 +144,7 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
fun closeMediaSession() {
|
||||
Logger.i(TAG, "closeMediaSession called");
|
||||
Logger.v(TAG, "closeMediaSession");
|
||||
stopForeground(true);
|
||||
|
||||
val focusRequest = _focusRequest;
|
||||
@@ -159,7 +165,7 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||
Logger.i(TAG, "updateMediaSession called");
|
||||
Logger.v(TAG, "updateMediaSession");
|
||||
var isUpdating = false;
|
||||
val video: IPlatformVideo;
|
||||
if(videoUpdated == null) {
|
||||
@@ -270,7 +276,7 @@ class MediaPlaybackService : Service() {
|
||||
val notif = builder.build();
|
||||
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
||||
|
||||
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "not null" else "null"} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
|
||||
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
|
||||
|
||||
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
|
||||
@@ -285,6 +291,7 @@ class MediaPlaybackService : Service() {
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
)
|
||||
.setState(state, pos, 1f, SystemClock.elapsedRealtime())
|
||||
|
||||
@@ -25,11 +25,17 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.*
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.CaptchaActivity
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.logging.AndroidLogConsumer
|
||||
import com.futo.platformplayer.logging.FileLogConsumer
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
@@ -52,10 +58,9 @@ import java.util.concurrent.TimeUnit
|
||||
class StateApp {
|
||||
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
|
||||
|
||||
/*
|
||||
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
|
||||
|
||||
|
||||
|
||||
fun getExternalRootDirectory(): File? {
|
||||
if(!externalRootDirectory.exists()) {
|
||||
val result = externalRootDirectory.mkdirs();
|
||||
@@ -65,6 +70,57 @@ class StateApp {
|
||||
}
|
||||
else
|
||||
return externalRootDirectory;
|
||||
}*/
|
||||
|
||||
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
||||
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
||||
if(isValidStorageUri(context, generalUri))
|
||||
return DocumentFile.fromTreeUri(context, generalUri!!);
|
||||
return null;
|
||||
}
|
||||
fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
|
||||
if(context is Context)
|
||||
requestDirectoryAccess(context, "General Files", "This directory is used to save auto-backups and other persistent files.", null) {
|
||||
if(it != null)
|
||||
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
|
||||
if(it != null && isValidStorageUri(context, it)) {
|
||||
Logger.i(TAG, "Changed external general directory: ${it}");
|
||||
Settings.instance.storage.storage_general = it.toString();
|
||||
Settings.instance.save();
|
||||
|
||||
onChanged?.invoke(getExternalGeneralDirectory(context));
|
||||
}
|
||||
else
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
|
||||
};
|
||||
};
|
||||
}
|
||||
fun getExternalDownloadDirectory(context: Context): DocumentFile? {
|
||||
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) };
|
||||
if(isValidStorageUri(context, downloadUri))
|
||||
return DocumentFile.fromTreeUri(context, downloadUri!!);
|
||||
return null;
|
||||
}
|
||||
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
|
||||
if(context is Context)
|
||||
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
|
||||
if(it != null)
|
||||
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION.or(Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)));
|
||||
if(it != null && isValidStorageUri(context, it)) {
|
||||
Logger.i(TAG, "Changed external download directory: ${it}");
|
||||
Settings.instance.storage.storage_general = it.toString();
|
||||
Settings.instance.save();
|
||||
|
||||
onChanged?.invoke(getExternalDownloadDirectory(context));
|
||||
}
|
||||
};
|
||||
}
|
||||
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
|
||||
if(uri == null)
|
||||
return false;
|
||||
|
||||
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
|
||||
}
|
||||
|
||||
//Scope
|
||||
@@ -171,20 +227,20 @@ class StateApp {
|
||||
return state;
|
||||
}
|
||||
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit)
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
|
||||
{
|
||||
if(activity is Context)
|
||||
{
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", null, 0,
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Ok", {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
if(path != null)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
.and(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.and(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
activity.launchForResult(intent, 99) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
@@ -299,7 +355,7 @@ class StateApp {
|
||||
}
|
||||
|
||||
Logger.onLogSubmitted.subscribe {
|
||||
scopeGetter().launch(Dispatchers.Main) {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
if (it != null) {
|
||||
UIDialogs.toast("Uploaded " + (it ?: "null"), true);
|
||||
@@ -377,16 +433,32 @@ class StateApp {
|
||||
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
|
||||
scheduleBackgroundWork(context, interval != 0, interval);
|
||||
|
||||
|
||||
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
||||
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
UIDialogs.toast("Missing general directory");
|
||||
changeExternalGeneralDirectory(context) {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
};
|
||||
}
|
||||
else {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
}
|
||||
}, "No Backup", {
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
});
|
||||
}
|
||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
if(context is IWithResultLauncher) {
|
||||
Logger.i(TAG, "Backup set without general directory, please select general external directory");
|
||||
changeExternalGeneralDirectory(context) {
|
||||
Logger.i(TAG, "Directory set, Auto-backup should resume to this location");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -510,7 +582,6 @@ class StateApp {
|
||||
if (_lastNetworkState != NetworkState.DISCONNECTED) {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i(TAG, "onConnectionAvailable emitted");
|
||||
onConnectionAvailable.emit();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to emit onConnectionAvailable", e)
|
||||
@@ -572,6 +643,33 @@ class StateApp {
|
||||
}
|
||||
}
|
||||
|
||||
private var hasCaptchaDialog = false;
|
||||
fun handleCaptchaException(client: JSClient, exception: ScriptCaptchaRequiredException) {
|
||||
Logger.w(HomeFragment.TAG, "[${client.name}] Plugin captcha required.", exception);
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
if(hasCaptchaDialog)
|
||||
return@launch;
|
||||
hasCaptchaDialog = true;
|
||||
UIDialogs.showConfirmationDialog(context, "Captcha required\nPlugin [${client.config.name}]", {
|
||||
CaptchaActivity.showCaptcha(context, client.config, exception.url, exception.body) {
|
||||
hasCaptchaDialog = false;
|
||||
StatePlugins.instance.setPluginCaptcha(client.config.id, it);
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlatform.instance.reloadClient(context, client.config.id);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(SourceDetailFragment.TAG, "Failed to reload client.", e)
|
||||
return@launch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
hasCaptchaDialog = false;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateApp";
|
||||
@SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive
|
||||
|
||||
@@ -17,11 +17,17 @@ import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.copyTo
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.getInputStream
|
||||
import com.futo.platformplayer.getNowDiffHours
|
||||
import com.futo.platformplayer.getOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.readBytes
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.writeBytes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -47,20 +53,22 @@ class StateBackup {
|
||||
|
||||
private val _autoBackupLock = Object();
|
||||
|
||||
private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||
val dir = DocumentFile.fromTreeUri(context, root);
|
||||
if(dir == null)
|
||||
throw IllegalStateException("Can't access external document files");
|
||||
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
return Pair(null, null);
|
||||
val uri = Settings.instance.storage.getStorageGeneralUri() ?: return Pair(null, null);
|
||||
val dir = DocumentFile.fromTreeUri(context, uri) ?: return Pair(null, null);
|
||||
val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null;
|
||||
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
|
||||
return Pair(mainBackupFile, secondaryBackupFile);
|
||||
}
|
||||
/*
|
||||
private fun getAutomaticBackupFiles(): Pair<File, File> {
|
||||
val dir = StateApp.instance.getExternalRootDirectory();
|
||||
if(dir == null)
|
||||
throw IllegalStateException("Can't access external files");
|
||||
return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
|
||||
@@ -77,10 +85,11 @@ class StateBackup {
|
||||
return password.padStart(32, '9');
|
||||
}
|
||||
fun hasAutomaticBackup(): Boolean {
|
||||
if(StateApp.instance.getExternalRootDirectory() == null)
|
||||
val context = StateApp.instance.contextOrNull ?: return false;
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
return false;
|
||||
val files = getAutomaticBackupFiles();
|
||||
return files.first.exists() || files.second.exists();
|
||||
val files = getAutomaticBackupDocumentFiles(context,);
|
||||
return files.first?.exists() ?: false || files.second?.exists() ?: false;
|
||||
}
|
||||
fun startAutomaticBackup(force: Boolean = false) {
|
||||
val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours();
|
||||
@@ -93,20 +102,27 @@ class StateBackup {
|
||||
try {
|
||||
Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)");
|
||||
synchronized(_autoBackupLock) {
|
||||
val context = StateApp.instance.contextOrNull ?: return@synchronized;
|
||||
val data = export();
|
||||
val zip = data.asZip();
|
||||
|
||||
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
|
||||
|
||||
val backupFiles = getAutomaticBackupFiles();
|
||||
val exportFile = backupFiles.first;
|
||||
if (exportFile.exists())
|
||||
exportFile.copyTo(backupFiles.second, true);
|
||||
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
||||
}
|
||||
}
|
||||
else {
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(context, true);
|
||||
val exportFile = backupFiles.first;
|
||||
if (exportFile?.exists() == true && backupFiles.second != null)
|
||||
exportFile!!.copyTo(context, backupFiles.second!!);
|
||||
exportFile!!.writeBytes(context, encryptedZip);
|
||||
|
||||
exportFile.writeBytes(encryptedZip);
|
||||
|
||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||
Settings.instance.save();
|
||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||
Settings.instance.save();
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Finished AutoBackup");
|
||||
}
|
||||
@@ -119,28 +135,22 @@ class StateBackup {
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage.
|
||||
//TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall)
|
||||
//TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall
|
||||
//TODO: DocumentFiles are not compatible with normal files and require its own system.
|
||||
//TODO: Investigate persistence of DOCUMENT_TREE files after uninstall...
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) {
|
||||
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||
if(ifExists && !hasAutomaticBackup()) {
|
||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Sadly on reinstalls of app this fails on file permissions.
|
||||
|
||||
Logger.i(TAG, "Starting AutoBackup restore");
|
||||
synchronized(_autoBackupLock) {
|
||||
|
||||
val backupFiles = getAutomaticBackupFiles();
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
|
||||
try {
|
||||
if (!backupFiles.first.exists() && withStream == null)
|
||||
if (backupFiles.first?.exists() != true)
|
||||
throw IllegalStateException("Backup file does not exist");
|
||||
|
||||
val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes();
|
||||
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
|
||||
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
|
||||
importZipBytes(context, scope, backupBytes);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
@@ -154,21 +164,21 @@ class StateBackup {
|
||||
else null;
|
||||
if(activity != null) {
|
||||
if(activity is IWithResultLauncher)
|
||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", backupFiles.first.parent?.toUri()) {
|
||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
|
||||
if(it != null) {
|
||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it);
|
||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
|
||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
||||
restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri));
|
||||
restoreAutomaticBackup(context, scope, password, ifExists);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
||||
if (!backupFiles.second.exists())
|
||||
if (backupFiles.second?.exists() != true)
|
||||
throw ex;
|
||||
|
||||
val backupBytesEncrypted = backupFiles.second.readBytes();
|
||||
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
|
||||
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
|
||||
importZipBytes(context, scope, backupBytes);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
@@ -108,6 +112,11 @@ class StateDownloads {
|
||||
fun getPlaylistDownload(playlistId: String): PlaylistDownloadDescriptor? {
|
||||
return _downloadPlaylists.findItem { it.id == playlistId };
|
||||
}
|
||||
fun savePlaylistDownload(playlistDownload: PlaylistDownloadDescriptor) {
|
||||
synchronized(playlistDownload.preventDownload) {
|
||||
_downloadPlaylists.save(playlistDownload);
|
||||
}
|
||||
}
|
||||
fun deleteCachedPlaylist(id: String) {
|
||||
val pdl = getPlaylistDownload(id);
|
||||
if(pdl != null)
|
||||
@@ -142,6 +151,19 @@ class StateDownloads {
|
||||
_downloading.delete(download);
|
||||
onDownloadsChanged.emit();
|
||||
}
|
||||
fun preventPlaylistDownload(download: VideoDownload) {
|
||||
if(download.video != null && download.groupID != null && download.groupType == VideoDownload.GROUP_PLAYLIST) {
|
||||
getPlaylistDownload(download.groupID!!)?.let {
|
||||
synchronized(it.preventDownload) {
|
||||
if(download.video?.url != null && !it.preventDownload.contains(download.video!!.url)) {
|
||||
it.preventDownload.add(download.video!!.url);
|
||||
savePlaylistDownload(it);
|
||||
Logger.w(TAG, "Preventing further download attempts in playlist [${it.id}] for [${download.name}]:${download.video?.url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForDownloadsTodos() {
|
||||
val hasPlaylistChanged = checkForOutdatedPlaylists();
|
||||
@@ -157,12 +179,15 @@ class StateDownloads {
|
||||
val playlistsDownloaded = getCachedPlaylists();
|
||||
for(playlist in playlistsDownloaded) {
|
||||
val playlistDownload = getPlaylistDownload(playlist.playlist.id) ?: continue;
|
||||
|
||||
if(playlist.playlist.videos.any{ getCachedVideo(it.id) == null }) {
|
||||
Logger.i(TAG, "Found new videos on playlist [${playlist.playlist.name}]");
|
||||
val toIgnore = playlistDownload.getPreventDownloadList();
|
||||
val missingVideoCount = playlist.playlist.videos.count { !toIgnore.contains(it.url) && getCachedVideo(it.id) == null };
|
||||
if(missingVideoCount > 0) {
|
||||
Logger.i(TAG, "Found new videos (${missingVideoCount}) on playlist [${playlist.playlist.name}] to download");
|
||||
continueDownload(playlistDownload, playlist.playlist);
|
||||
hasChanged = true;
|
||||
}
|
||||
else
|
||||
Logger.v(TAG, "Offline playlist [${playlist.playlist.name}] is up to date");
|
||||
}
|
||||
return hasChanged;
|
||||
}
|
||||
@@ -171,6 +196,11 @@ class StateDownloads {
|
||||
var hasNew = false;
|
||||
for(item in playlist.videos) {
|
||||
val existing = getCachedVideo(item.id);
|
||||
|
||||
if(!playlistDownload.shouldDownload(item)) {
|
||||
Logger.i(TAG, "Not downloading for playlist [${playlistDownload.id}] Video [${item.name}]:${item.url}")
|
||||
continue;
|
||||
}
|
||||
if(existing == null) {
|
||||
val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null };
|
||||
if(ongoingDownload != null) {
|
||||
@@ -291,6 +321,32 @@ class StateDownloads {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadSubtitles(subtitle: ISubtitleSource, contentResolver: ContentResolver): SubtitleRawSource? {
|
||||
val subtitleUri = subtitle.getSubtitlesURI();
|
||||
if(subtitleUri == null)
|
||||
return null;
|
||||
var subtitles: String? = null;
|
||||
if ("file" == subtitleUri.scheme) {
|
||||
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 NotImplementedError("Unsuported scheme");
|
||||
}
|
||||
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null;
|
||||
}
|
||||
|
||||
fun cleanupDownloads(): Pair<Int, Long> {
|
||||
val expected = getDownloadedVideos();
|
||||
val validFiles = HashSet(expected.flatMap { it.videoSource.map { it.filePath } + it.audioSource.map { it.filePath } });
|
||||
|
||||
@@ -70,11 +70,11 @@ class StatePlatform {
|
||||
//Pools always follow the behavior of the base client. So if user disables a plugin, it kills all pooled clients.
|
||||
//Each pooled client adds additional memory usage.
|
||||
//WARNING: Be careful with pooling some calls, as they might use the plugin subsequently afterwards. For example pagers might block plugins in future calls.
|
||||
private val _mainClientPool = PlatformMultiClientPool(2); //Used for all main user events, generally user critical
|
||||
private val _pagerClientPool = PlatformMultiClientPool(2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
|
||||
private val _channelClientPool = PlatformMultiClientPool(15); //Used primarily for subscription/background channel fetches
|
||||
private val _trackerClientPool = PlatformMultiClientPool(1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool(1); //Used exclusively for live events
|
||||
private val _mainClientPool = PlatformMultiClientPool("Main", 2); //Used for all main user events, generally user critical
|
||||
private val _pagerClientPool = PlatformMultiClientPool("Pagers", 2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
|
||||
private val _channelClientPool = PlatformMultiClientPool("Channels", 15); //Used primarily for subscription/background channel fetches
|
||||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||
|
||||
|
||||
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
|
||||
@@ -172,7 +172,11 @@ class StatePlatform {
|
||||
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||
|
||||
_availableClients.add(JSClient(context, plugin));
|
||||
val client = JSClient(context, plugin);
|
||||
client.onCaptchaException.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
}
|
||||
_availableClients.add(client);
|
||||
}
|
||||
|
||||
if(_availableClients.distinctBy { it.id }.count() < _availableClients.size)
|
||||
@@ -287,6 +291,9 @@ class StatePlatform {
|
||||
StatePlugins.instance.getPlugin(id)
|
||||
?: throw IllegalStateException("Client existed, but plugin config didn't")
|
||||
);
|
||||
newClient.onCaptchaException.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
}
|
||||
|
||||
synchronized(_clientsLock) {
|
||||
if (_enabledClients.contains(client)) {
|
||||
@@ -399,13 +406,15 @@ class StatePlatform {
|
||||
return@async searchResult;
|
||||
} catch(ex: Throwable) {
|
||||
Logger.e(TAG, "getHomeRefresh", ex);
|
||||
return@async null;
|
||||
throw ex;
|
||||
//return@async null;
|
||||
}
|
||||
});
|
||||
}.toList();
|
||||
|
||||
val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager();
|
||||
val toAwait = deferred.filter { it.second != finishedPager.first };
|
||||
|
||||
return RefreshDistributionContentPager(
|
||||
listOf(finishedPager.second),
|
||||
toAwait.map { it.second },
|
||||
@@ -616,9 +625,13 @@ class StatePlatform {
|
||||
}
|
||||
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
|
||||
fun getChannelClient(url : String) : IPlatformClient = getChannelClientOrNull(url)
|
||||
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
||||
fun getChannelClientOrNull(url : String) : IPlatformClient? = getEnabledClients().find { it.isChannelUrl(url) };
|
||||
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
||||
if(exclude == null)
|
||||
getEnabledClients().find { it.isChannelUrl(url) }
|
||||
else
|
||||
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
|
||||
|
||||
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
|
||||
Logger.i(TAG, "Platform - getChannel");
|
||||
@@ -629,9 +642,9 @@ class StatePlatform {
|
||||
return _scope.async { getChannelLive(url, updateSubscriptions) };
|
||||
}
|
||||
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager<IPlatformContent> {
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - getChannelVideos");
|
||||
val baseClient = getChannelClient(channelUrl);
|
||||
val baseClient = getChannelClient(channelUrl, ignorePlugins);
|
||||
val clientCapabilities = baseClient.getChannelCapabilities();
|
||||
|
||||
val client = if(usePooledClients > 1)
|
||||
@@ -657,11 +670,11 @@ class StatePlatform {
|
||||
if(sub != null) {
|
||||
val daysSinceLiveStream = sub.lastLiveStream.getNowDiffDays()
|
||||
if(daysSinceLiveStream > 7) {
|
||||
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]");
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_LIVE);
|
||||
}
|
||||
if(daysSinceLiveStream > 14) {
|
||||
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]");
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class StatePlayer {
|
||||
private val MIN_BUFFER_DURATION = 10000;
|
||||
private val MAX_BUFFER_DURATION = 60000;
|
||||
private val MIN_PLAYBACK_START_BUFFER = 500;
|
||||
private val MIN_PLAYBACK_RESUME_BUFFER = 1000;
|
||||
private val MIN_PLAYBACK_RESUME_BUFFER = 2500;
|
||||
private val BUFFER_SIZE = 1024 * 64;
|
||||
|
||||
var isOpen : Boolean = false
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
@@ -265,6 +266,12 @@ class StatePlaylists {
|
||||
builder.messages.add("${name}:[${it}] is no longer available");
|
||||
return@map null;
|
||||
}
|
||||
catch(ex: NoPlatformClientException) {
|
||||
throw ReconstructionException(name, "No source enabled for [${it}]", ex);
|
||||
//TODO: Propagate this to dialog, and then back, allowing users to enable plugins...
|
||||
//builder.messages.add("No source enabled for [${it}]");
|
||||
//return@map null;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
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.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -27,12 +28,20 @@ class StatePlugins {
|
||||
private val TAG = "StatePlugins";
|
||||
|
||||
private val FORCE_REINSTALL_EMBEDDED = false;
|
||||
private var _isFirstEmbedUpdate = true;
|
||||
|
||||
private val _pluginScripts = FragmentedStorage.getDirectory<PluginScriptsDirectory>();
|
||||
private var _plugins = FragmentedStorage.storeJson<SourcePluginDescriptor>("plugins")
|
||||
.load();
|
||||
private val iconsDir = FragmentedStorage.getDirectory<PluginIconStorage>();
|
||||
|
||||
|
||||
private val _syncObject = Object()
|
||||
private var _embeddedSources: Map<String, String>? = null
|
||||
private var _embeddedSourcesDefault: List<String>? = null
|
||||
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
|
||||
|
||||
|
||||
fun getPluginIconOrNull(id: String): ImageVariable? {
|
||||
if(iconsDir.hasIcon(id))
|
||||
return iconsDir.getIconBinary(id);
|
||||
@@ -53,18 +62,6 @@ class StatePlugins {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class PluginConfig(
|
||||
val SOURCES_EMBEDDED: Map<String, String>,
|
||||
val SOURCES_EMBEDDED_DEFAULT: List<String>,
|
||||
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
|
||||
)
|
||||
|
||||
private val _syncObject = Object()
|
||||
private var _embeddedSources: Map<String, String>? = null
|
||||
private var _embeddedSourcesDefault: List<String>? = null
|
||||
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
|
||||
|
||||
private fun ensureSourcesConfigLoaded(context: Context) {
|
||||
if (_embeddedSources != null && _embeddedSourcesDefault != null && _sourcesUnderConstruction != null) {
|
||||
return
|
||||
@@ -122,8 +119,11 @@ class StatePlugins {
|
||||
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling");
|
||||
deletePlugin(embedded.key);
|
||||
}
|
||||
else if(existing != null && _isFirstEmbedUpdate)
|
||||
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
|
||||
}
|
||||
}
|
||||
_isFirstEmbedUpdate = false;
|
||||
}
|
||||
fun installMissingEmbeddedPlugins(context: Context) {
|
||||
val plugins = getPlugins();
|
||||
@@ -373,7 +373,7 @@ class StatePlugins {
|
||||
if(icon != null)
|
||||
iconsDir.saveIconBinary(config.id, icon);
|
||||
|
||||
_plugins.save(SourcePluginDescriptor(config, null, flags));
|
||||
_plugins.save(SourcePluginDescriptor(config, null, null, flags));
|
||||
return null;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -408,6 +408,18 @@ class StatePlugins {
|
||||
}
|
||||
}
|
||||
|
||||
fun setPluginCaptcha(id: String, captcha: SourceCaptchaData?) {
|
||||
if(id == StateDeveloper.DEV_ID) {
|
||||
StatePlatform.instance.getDevClient()?.let {
|
||||
it.setCaptcha(captcha);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
|
||||
descriptor.updateCaptcha(captcha);
|
||||
_plugins.save(descriptor);
|
||||
}
|
||||
fun setPluginAuth(id: String, auth: SourceAuth?) {
|
||||
if(id == StateDeveloper.DEV_ID) {
|
||||
StatePlatform.instance.getDevClient()?.let {
|
||||
@@ -422,6 +434,13 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
private data class PluginConfig(
|
||||
val SOURCES_EMBEDDED: Map<String, String>,
|
||||
val SOURCES_EMBEDDED_DEFAULT: List<String>,
|
||||
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
|
||||
)
|
||||
|
||||
companion object {
|
||||
private var _instance : StatePlugins? = null;
|
||||
val instance : StatePlugins
|
||||
|
||||
@@ -126,7 +126,7 @@ class StatePolycentric {
|
||||
}
|
||||
}
|
||||
|
||||
fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent> {
|
||||
fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
//TODO: Currently abusing subscription concurrency for parallelism
|
||||
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
|
||||
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
|
||||
@@ -138,7 +138,7 @@ class StatePolycentric {
|
||||
return@mapNotNull null;
|
||||
}
|
||||
|
||||
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
|
||||
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, ignorePlugins);
|
||||
}.toTypedArray();
|
||||
|
||||
val pager = MultiChronoContentPager(pagers);
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.*
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
@@ -12,6 +14,7 @@ import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.findNonRuntimeException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
@@ -67,7 +70,7 @@ class StateSubscriptions {
|
||||
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
|
||||
}
|
||||
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) {
|
||||
Logger.i(TAG, "updateSubscriptionFeed");
|
||||
Logger.v(TAG, "updateSubscriptionFeed");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
synchronized(_globalSubscriptionsLock) {
|
||||
if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) {
|
||||
@@ -230,8 +233,11 @@ class StateSubscriptions {
|
||||
var finished = 0;
|
||||
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
|
||||
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
|
||||
val failedPlugins = arrayListOf<String>();
|
||||
for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) {
|
||||
tasks.add(_subscriptionsPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
|
||||
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
|
||||
|
||||
var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null;
|
||||
val getProfileTime = measureTimeMillis {
|
||||
try {
|
||||
@@ -258,9 +264,9 @@ class StateSubscriptions {
|
||||
val time = measureTimeMillis {
|
||||
val profile = polycentricProfile?.profile
|
||||
pager = if (profile != null)
|
||||
StatePolycentric.instance.getChannelContent(profile, true, concurrency)
|
||||
StatePolycentric.instance.getChannelContent(profile, true, concurrency, toIgnore)
|
||||
else
|
||||
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency);
|
||||
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency, toIgnore);
|
||||
|
||||
if (cacheScope != null)
|
||||
pager = ChannelContentCache.cachePagerResults(cacheScope, pager) {
|
||||
@@ -276,12 +282,22 @@ class StateSubscriptions {
|
||||
);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Subscription [${sub.channel.name}] failed", ex);
|
||||
finished++;
|
||||
onProgress?.invoke(finished, tasks.size);
|
||||
val channelEx = ChannelException(sub.channel, ex);
|
||||
synchronized(exceptionMap) {
|
||||
exceptionMap.put(sub, channelEx);
|
||||
}
|
||||
if(ex is ScriptCaptchaRequiredException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a captcha issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(TAG, "Subscriptions fetch ignoring plugin [${ex.config.name}] due to Captcha");
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!withCacheFallback)
|
||||
throw channelEx;
|
||||
else {
|
||||
|
||||
@@ -118,6 +118,7 @@ class ManagedStore<T>{
|
||||
val builder = ReconstructStore.Builder();
|
||||
|
||||
for (recon in items) {
|
||||
onProgress?.invoke(0, total);
|
||||
//Retry once
|
||||
for (i in 0 .. 1) {
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.graphics.drawable.Animatable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.futo.platformplayer.R
|
||||
|
||||
class Loader : LinearLayout {
|
||||
@@ -15,7 +17,7 @@ class Loader : LinearLayout {
|
||||
private val _animatable: Animatable;
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_loader, this, true);
|
||||
inflate(context, R.layout.view_loader, this);
|
||||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_animatable = _imageLoader.drawable as Animatable;
|
||||
|
||||
@@ -29,6 +31,18 @@ class Loader : LinearLayout {
|
||||
|
||||
visibility = View.GONE;
|
||||
}
|
||||
constructor(context: Context, automatic: Boolean, height: Int = -1) : super(context) {
|
||||
inflate(context, R.layout.view_loader, this);
|
||||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_animatable = _imageLoader.drawable as Animatable;
|
||||
_automatic = automatic;
|
||||
|
||||
if(height > 0) {
|
||||
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
|
||||
}
|
||||
|
||||
visibility = View.GONE;
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
|
||||
@@ -67,7 +67,7 @@ class CommentViewHolder : ViewHolder {
|
||||
processHandle.opinion(c.reference, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+19
-3
@@ -3,8 +3,10 @@ package com.futo.platformplayer.views.adapters
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
@@ -18,6 +20,7 @@ class PreviewPlaceholderViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
private val _loader: ImageView;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _error: TextView;
|
||||
|
||||
val context: Context;
|
||||
|
||||
@@ -30,15 +33,28 @@ class PreviewPlaceholderViewHolder : ContentPreviewViewHolder {
|
||||
context = itemView.context;
|
||||
_loader = itemView.findViewById(R.id.loader);
|
||||
_platformIndicator = itemView.findViewById(R.id.thumbnail_platform);
|
||||
_error = itemView.findViewById(R.id.text_error);
|
||||
|
||||
(_loader.drawable as Animatable?)?.start(); //TODO: stop?
|
||||
(_loader.drawable as Animatable?)?.start();
|
||||
}
|
||||
|
||||
override fun bind(content: IPlatformContent) {
|
||||
if(content is PlatformContentPlaceholder)
|
||||
if(content is PlatformContentPlaceholder) {
|
||||
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
|
||||
else
|
||||
_error.text = content.error?.message ?: "";
|
||||
if(content.error != null) {
|
||||
_loader.visibility = View.GONE;
|
||||
(_loader.drawable as Animatable?)?.stop();
|
||||
}
|
||||
else {
|
||||
_loader.visibility = View.VISIBLE;
|
||||
(_loader.drawable as Animatable?)?.start();
|
||||
}
|
||||
}
|
||||
else {
|
||||
_platformIndicator.clearPlatform();
|
||||
(_loader.drawable as Animatable?)?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
override fun preview(video: IPlatformContentDetails?, paused: Boolean) { }
|
||||
|
||||
@@ -78,8 +78,6 @@ class AnnouncementView : LinearLayout {
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
Logger.i(TAG, "onAttachedToWindow");
|
||||
|
||||
super.onAttachedToWindow()
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||
_scope?.launch(Dispatchers.Main) {
|
||||
@@ -91,20 +89,19 @@ class AnnouncementView : LinearLayout {
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
Logger.i(TAG, "onDetachedFromWindow");
|
||||
|
||||
super.onDetachedFromWindow()
|
||||
StateAnnouncement.instance.onAnnouncementChanged.remove(this)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
Logger.i(TAG, "refresh");
|
||||
Logger.v(TAG, "refresh");
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements(_category);
|
||||
setAnnouncement(announcements.firstOrNull(), announcements.size);
|
||||
}
|
||||
|
||||
private fun setAnnouncement(announcement: Announcement?, count: Int) {
|
||||
Logger.i(TAG, "setAnnouncement announcement=$announcement count=$count");
|
||||
if(count == 0 && announcement == null)
|
||||
Logger.i(TAG, "setAnnouncement announcement=$announcement count=$count");
|
||||
|
||||
_currentAnnouncement = announcement;
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class GestureControlView : LinearLayout {
|
||||
} else {
|
||||
val rx = p0.x / width;
|
||||
val ry = p0.y / height;
|
||||
Logger.i(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
|
||||
Logger.v(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
|
||||
if (ry > 0.1 && ry < 0.9) {
|
||||
if (_isFullScreen && rx < 0.4) {
|
||||
startAdjustingBrightness();
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
@@ -33,6 +37,28 @@ class FieldForm : LinearLayout {
|
||||
_root = findViewById(R.id.field_form_root);
|
||||
}
|
||||
|
||||
fun fromObject(scope: CoroutineScope, obj : Any, onLoaded: (()->Unit)? = null) {
|
||||
_root.removeAllViews();
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
for (field in newFields) {
|
||||
if (field !is View)
|
||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||
|
||||
_root.addView(field as View);
|
||||
field.onChanged.subscribe { a1, a2 ->
|
||||
onChanged.emit(a1, a2);
|
||||
};
|
||||
}
|
||||
_fields = newFields;
|
||||
|
||||
onLoaded?.invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
fun fromObject(obj : Any) {
|
||||
_root.removeAllViews();
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
|
||||
@@ -68,6 +68,7 @@ class ActiveDownloadItem: LinearLayout {
|
||||
|
||||
_videoCancel.setOnClickListener {
|
||||
StateDownloads.instance.removeDownload(_download);
|
||||
StateDownloads.instance.preventPlaylistDownload(_download);
|
||||
};
|
||||
|
||||
_download.onProgressChanged.subscribe(this) {
|
||||
|
||||
+7
-1
@@ -40,7 +40,7 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
_groupItems = listOf();
|
||||
}
|
||||
|
||||
constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>): super(context){
|
||||
constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>, hideButtons: Boolean = false): super(context){
|
||||
init(animated, okText);
|
||||
_container = parent;
|
||||
if(!_container!!.children.contains(this)) {
|
||||
@@ -50,6 +50,12 @@ class SlideUpMenuOverlay : RelativeLayout {
|
||||
_textTitle.text = titleText;
|
||||
_groupItems = items;
|
||||
|
||||
if(hideButtons) {
|
||||
_textCancel.visibility = GONE;
|
||||
_textOK.visibility = GONE;
|
||||
_textTitle.textAlignment = TextView.TEXT_ALIGNMENT_CENTER;
|
||||
}
|
||||
|
||||
setItems(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Logger.i(TAG, "onPlaybackStateChanged $playbackState");
|
||||
Logger.v(TAG, "onPlaybackStateChanged $playbackState");
|
||||
val timeLeft = abs(position - duration);
|
||||
|
||||
if (playbackState == ExoPlayer.STATE_ENDED) {
|
||||
|
||||
@@ -130,9 +130,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
|
||||
Logger.i(TAG, "Attached onConnectionAvailable listener.");
|
||||
Logger.v(TAG, "Attached onConnectionAvailable listener.");
|
||||
StateApp.instance.onConnectionAvailable.subscribe(_referenceObject) {
|
||||
Logger.i(TAG, "onConnectionAvailable");
|
||||
Logger.v(TAG, "onConnectionAvailable");
|
||||
|
||||
val pos = position;
|
||||
val dur = duration;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/black">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="4dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="Please enter the captcha and close when finished" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_close"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:text="CLOSE" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<WebView
|
||||
android:id="@+id/web_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</LinearLayout>
|
||||
@@ -52,6 +52,13 @@
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<com.futo.platformplayer.views.Loader
|
||||
android:id="@+id/loader"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:layout_marginTop="15dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp" />
|
||||
|
||||
<com.futo.platformplayer.views.fields.FieldForm
|
||||
android:id="@+id/settings_form"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<LinearLayout android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingStart="15dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp" />
|
||||
android:layout_marginBottom="28dp">
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -11,7 +11,7 @@
|
||||
android:id="@+id/field_group_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:textSize="20dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/defaults"
|
||||
|
||||
@@ -303,6 +303,7 @@
|
||||
android:id="@+id/videodetail_channel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
@@ -311,21 +312,22 @@
|
||||
|
||||
<com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
android:id="@+id/creator_thumbnail"
|
||||
android:layout_width="27dp"
|
||||
android:layout_height="27dp" />
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/videodetail_channel_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingEnd="10dp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="-4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
tools:text="Channel Name" />
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
@@ -49,6 +50,21 @@
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_gravity="center" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_error"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginBottom="100dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingRight="20dp"
|
||||
android:textSize="9dp"
|
||||
android:textColor="@color/pastel_red" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -16,6 +16,7 @@
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:gravity="center">
|
||||
|
||||
<ImageView
|
||||
@@ -31,7 +32,7 @@
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:paddingTop="27dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:gravity="center">
|
||||
|
||||
<com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
@@ -43,7 +44,22 @@
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_error"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingRight="20dp"
|
||||
android:textSize="9dp"
|
||||
android:textColor="@color/pastel_red" />
|
||||
</LinearLayout>
|
||||
Submodule app/src/stable/assets/sources/youtube updated: 123960682a...1c34bb0163
Submodule app/src/unstable/assets/sources/youtube updated: 2061a75ec1...1c34bb0163
@@ -0,0 +1,62 @@
|
||||
# Authentication
|
||||
Grayjay supports offering platform login for a plugin. This however comes with several security concerns that we attempt to alleviate partially.
|
||||
|
||||
The goal of the authentication system is to provide plugins the ability to make authenticated requests without directly exposing credentials and tokens to the plugin. This is done by keeping all this data on the app side, and never passing it to the plugin.
|
||||
|
||||
>:warning: **This is not bulletproof**
|
||||
>Depending on the platform, the plugin still has full access to making authenticated requests, including ones that may expose your account to danger (like changing settings). Or if a platform exposes values (insecurely) in the response data (not headers).
|
||||
>
|
||||
>You should always only login (and install for that matter) plugins you trust.
|
||||
|
||||
How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](_blank)).
|
||||
This documentation will exclusively focus on configuring authentication and how it behaves.
|
||||
|
||||
## How it works
|
||||
The authentication system works by allowing plugins to provide a login url, and a set of required headers/cookies/urls. When the user tries to log in, it will open the provided login url in an in-app webbrowser. Once all requirements are met, it will close this webbrowser and save the required data encrypted to app storage.
|
||||
|
||||
These authentication configs are put in the plugin config under the ```authentication``` property.
|
||||
## Example
|
||||
Here is an example of such an authentication configuration:
|
||||
|
||||
```json
|
||||
"authentication": {
|
||||
"loginUrl": "https://platform.com/some/login/url",
|
||||
"completionUrl": "https://platform.com/some/required/page", //Optional
|
||||
"loginButton": ".someContainer div .someButton" //Optional
|
||||
"userAgent": "Some User Agent", //Optional
|
||||
"domainHeadersToFind": { //Optional
|
||||
".platform.com": ["authorization"],
|
||||
"subdomain.platform.com": ["someHeader"],
|
||||
".somerelatedplatform.com": ["someOtherHeader"],
|
||||
},
|
||||
"cookiesToFind": ["someCookieToFind", "someOtherCookieToFind"], //Optional
|
||||
//"cookiesExclOthers": false //Optional
|
||||
//"allowedDomains": ["platform.com", "subdomain.platform.com"] //Optional
|
||||
}
|
||||
```
|
||||
Most platforms will only need a single header or cookie to function, but for some you may need very specific cookies for specific subdomains.
|
||||
|
||||
| | Property | Usage |
|
||||
|--|--|--|
|
||||
| **Mandatory** | ```loginUrl``` | Used to set the initial url for the login browser. |
|
||||
| Optional | ```completionUrl``` | Can be used to set a url that needs to be visited before concluding login. |
|
||||
| Optional | ```loginButton``` | Can be used to trigger a html element by providing a query selector to a single html element. This button is then "clicked" after the page finishes loading. This supports full query selector including classes, ids, tags, and more advanced like :first-child. |
|
||||
| Optional | ```userAgent``` | Can be used to set the user-agent of the browser during login. |
|
||||
| Optional | ```domainHeadersToFind``` | Can be used to find headers for specific subdomains. |
|
||||
| Optional | ```cookiesToFind``` | Can be used to find specific cookies. |
|
||||
| Optional | ```cookiesExclOthers``` |Can be used in the niche scenario where all other cookies should be disgarded when authenticated request are used. This is rather uncommon. |
|
||||
| Optional | ```allowedDomains``` | Can be used to only fulfill the above requirements on the domains specified in this property, any other domains may be cancelled. (NOT USEFUL FOR MOST PLUGINS) |
|
||||
|
||||
|
||||
## Header Behavior
|
||||
Headers are exclusively applied to the domains they are retrieved from. A plugin CANNOT send a header to a domain that it is not related to.
|
||||
|
||||
>:warning: **Plugins can elevate a header to a parent domain**
|
||||
>However a plugin can elevate a header to a parent domain. Meaning that if a header is retrieved in a request to ```somedomain.platform.com```, by defining the header for ```.platform.com``` it will be send to all requests of to any ```platform.com``` domain. This might be required for some platforms.
|
||||
|
||||
## Cookie Behavior
|
||||
By default, when authentication requests are made, the authenticated client will behave similar to that of a normal browser. Meaning that if the server you are communicating with sets new cookies, the client will use those cookies instead. These new cookies are NOT saved to disk, meaning that whenever that plugin reloads the cookies will revert to those assigned at login.
|
||||
|
||||
This behavior can be modified by using custom http clients as described in the http package documentation.
|
||||
(See [Package: Http](_blank))
|
||||
|
||||
Reference in New Issue
Block a user