Compare commits

..

23 Commits

Author SHA1 Message Date
Kelvin 081ae1dd88 Move unhandled exception announcement check to correct method 2023-10-16 22:05:47 +02:00
Kelvin 374d9950be Plugin disable only after no ongoing v8 calls to reduce crashes, errors of placeholder loaders now visible, cancel retry on home now removes loader 2023-10-16 22:04:19 +02:00
Kelvin 9ffdf39f13 Permanently stop playlist video download on cancel, Use detailed video download overlay in overviews 2023-10-13 19:09:07 +02:00
Kelvin 8bb1ff87c0 Fix issues with attempting to download sources that are not supported (including mixed playlists) 2023-10-13 18:00:01 +02:00
Kelvin 67e29999ef Add missing use 2023-10-12 19:21:14 +02:00
Kelvin f3f13a71dc New auto-backup storage using the Storage Access Framework, minor dialog tweaks, minor settings ui tweaks 2023-10-12 19:18:56 +02:00
Kelvin 5155423a1e Improve auth doc 2023-10-11 23:48:03 +02:00
Kelvin a7d558e48d Additional docs 2023-10-11 23:30:29 +02:00
Kelvin 7afd75c712 Fix missing next override for headphone controls 2023-10-11 22:50:36 +02:00
Kelvin 10a661ad4c Minor UI tweak, allow for settings reload, async settings load (with loader) 2023-10-11 22:15:52 +02:00
Kelvin 201fe6f0df Minor fix/comment 2023-10-11 18:01:49 +02:00
Kelvin f76a5b5f01 Cleaning up some logs, reducing retry intervals, planned live stream auto refresh, tweak some buffer timings, fixing some scopes 2023-10-11 17:58:04 +02:00
Kelvin 3a7e477e9b Additional plugin usage pooling/isolating 2023-10-10 21:18:13 +02:00
Kelvin b1aae244de Basic History backup & export, Minor pooling cleanup, some docs 2023-10-10 20:32:39 +02:00
Kelvin 7ebd8f13c2 Add some main thread checks, change incorrect cache size 2023-10-09 21:26:27 +02:00
Kelvin 1768d73c01 Temporary workaround for auto backup restore on >Android11 2023-10-09 17:33:29 +02:00
Kelvin ebcb894011 Fix download errors causing UI blackout 2023-10-09 14:17:37 +02:00
Koen 25cbdcb504 Fixed PlaybackTracker slowness. 2023-09-29 17:13:50 +02:00
Koen 14ed45e833 Fixed subscription imports generally and an additional fix for Twitch subscription imports. 2023-09-29 14:52:41 +02:00
Koen e365e0219e Implemented Rumble creator search and added logging for Failed search channels error. 2023-09-29 13:30:47 +02:00
Koen 1531a558a5 Properly check for different author and show it as a warning when the author has changed. Properly show signature warnings when installing a script. 2023-09-28 16:44:40 +02:00
Koen f19b7fa584 Added script to generate private key. Modified source header view to show signature status. 2023-09-28 16:21:29 +02:00
Koen c8ab7f7d42 Fixes to deploy scripts. 2023-09-28 12:21:31 +02:00
104 changed files with 1743 additions and 338 deletions
@@ -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,8 +16,12 @@ 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
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod
@@ -51,6 +56,11 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
return ex;
}
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.joinToString { it.toString() });
}
fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
@@ -63,8 +73,20 @@ fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this);
}
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
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 {
@@ -42,7 +42,8 @@ class AddSourceActivity : AppCompatActivity() {
private val _client = ManagedHttpClient();
private var _config : SourcePluginConfig? = null;
private var _config: SourcePluginConfig? = null;
private var _script: String? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -81,7 +82,7 @@ class AddSourceActivity : AppCompatActivity() {
}
_buttonInstall.setOnClickListener {
_config?.let {
install(_config!!);
install(_config!!, _script!!);
};
};
@@ -114,6 +115,7 @@ class AddSourceActivity : AppCompatActivity() {
setLoading(true);
lifecycleScope.launch(Dispatchers.IO) {
val config: SourcePluginConfig;
try {
val configResp = _client.get(url);
if(!configResp.isOk)
@@ -121,33 +123,51 @@ class AddSourceActivity : AppCompatActivity() {
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
val config = SourcePluginConfig.fromJson(configJson, url);
withContext(Dispatchers.Main) {
loadConfig(config);
}
}
catch(ex: SerializationException) {
config = SourcePluginConfig.fromJson(configJson, url);
} catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
"Invalid Config Format", null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
};
}
catch(ex: Exception) {
return@launch;
} catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex);
};
return@launch;
}
val script: String?
try {
val scriptResp = _client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex);
};
return@launch;
}
withContext(Dispatchers.Main) {
loadConfig(config, script);
}
};
}
fun loadConfig(config: SourcePluginConfig) {
private fun loadConfig(config: SourcePluginConfig, script: String) {
_config = config;
_script = script;
_sourceHeader.loadConfig(config);
_sourceHeader.loadConfig(config, script);
_sourcePermissions.removeAllViews();
_sourceWarnings.removeAllViews();
@@ -171,7 +191,7 @@ class AddSourceActivity : AppCompatActivity() {
val pastelRed = resources.getColor(R.color.pastel_red);
for(warning in config.getWarnings())
for(warning in config.getWarnings(script))
_sourceWarnings.addView(
SourceInfoView(this,
R.drawable.ic_security_pred,
@@ -182,8 +202,8 @@ class AddSourceActivity : AppCompatActivity() {
setLoading(false);
}
fun install(config: SourcePluginConfig) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config) {
fun install(config: SourcePluginConfig, script: String) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it)
backToSources();
}
@@ -0,0 +1,9 @@
package com.futo.platformplayer.activities
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
interface IWithResultLauncher {
fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit);
}
@@ -10,6 +10,9 @@ import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.WindowCompat
@@ -24,6 +27,7 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -48,7 +52,7 @@ import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.*
class MainActivity : AppCompatActivity {
class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Move to dimensions
private val HEIGHT_MENU_DP = 48f;
@@ -364,6 +368,7 @@ class MainActivity : AppCompatActivity {
//startActivity(Intent(this, TestActivity::class.java));
}
/*
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@@ -387,7 +392,7 @@ class MainActivity : AppCompatActivity {
override fun onResume() {
super.onResume();
Logger.i(TAG, "onResume")
Logger.v(TAG, "onResume")
val curOrientation = _orientationManager.orientation;
@@ -403,13 +408,10 @@ class MainActivity : AppCompatActivity {
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);
@@ -422,13 +424,13 @@ class MainActivity : AppCompatActivity {
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;
}
@@ -717,22 +719,20 @@ class MainActivity : AppCompatActivity {
}
_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();
@@ -892,6 +892,28 @@ class MainActivity : AppCompatActivity {
_fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt());
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
companion object {
private val TAG = "MainActivity"
@@ -6,15 +6,22 @@ import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import android.widget.LinearLayout
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
class SettingsActivity : AppCompatActivity() {
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;
@@ -30,9 +37,10 @@ class SettingsActivity : AppCompatActivity() {
_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();
};
@@ -56,6 +64,15 @@ class SettingsActivity : AppCompatActivity() {
}
};
_lastActivity = this;
reloadSettings();
}
fun reloadSettings() {
_loader.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
};
}
override fun onResume() {
@@ -78,6 +95,28 @@ class SettingsActivity : AppCompatActivity() {
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
@@ -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;
@@ -6,6 +6,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
import com.futo.polycentric.core.combineHashCodes
import okhttp3.internal.platform.Platform
@kotlinx.serialization.Serializable
class PlatformID {
@@ -40,6 +41,8 @@ class PlatformID {
}
companion object {
val NONE = PlatformID("Unknown", null);
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
val contextName = "PlatformID";
return PlatformID(
@@ -49,5 +52,9 @@ class PlatformID {
value.getOrDefault(config, "claimType", contextName, 0) ?: 0,
value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1);
}
fun asUrlID(url: String): PlatformID {
return PlatformID("URL", url, null);
}
}
}
@@ -0,0 +1,40 @@
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(name: String, maxCap: Int = -1) {
_name = name;
_maxCap = if(maxCap > 0)
maxCap
else 99;
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
if(_isFake)
return parentClient;
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity.coerceAtMost(_maxCap));
}
//Allows for testing disabling pooling without changing callers
fun asFake(): PlatformMultiClientPool {
_isFake = true;
return this;
}
}
@@ -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
}
@@ -26,6 +26,8 @@ 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.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.logging.Logger
@@ -561,11 +563,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) {}
@@ -4,6 +4,7 @@ import android.net.Uri
import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.decodeFromString
import java.net.URL
import java.util.*
@@ -78,6 +79,15 @@ class SourcePluginConfig(
fun getWarnings(scriptToCheck: String? = null) : List<Pair<String,String>> {
val list = mutableListOf<Pair<String,String>>();
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) {
list.add(Pair(
"Different Author",
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
}
}
if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty())
list.add(Pair(
"Missing Signature",
@@ -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,11 +1,14 @@
package com.futo.platformplayer.api.media.platforms.js.models
import android.os.Looper
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.warnIfMainThread
abstract class JSPager<T> : IPager<T> {
protected val plugin: V8Plugin;
@@ -37,6 +40,8 @@ abstract class JSPager<T> : IPager<T> {
}
override fun nextPage() {
warnIfMainThread("JSPager.nextPage");
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
pager.invoke("nextPage", arrayOf<Any>());
};
@@ -53,6 +58,8 @@ abstract class JSPager<T> : IPager<T> {
}
override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let {
if(!_resultChanged)
return@let it;
@@ -6,6 +6,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.warnIfMainThread
class JSPlaybackTracker: IPlaybackTracker {
private val _config: IV8PluginConfig;
@@ -20,6 +21,7 @@ class JSPlaybackTracker: IPlaybackTracker {
private set;
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
warnIfMainThread("JSPlaybackTracker.constructor");
if(!obj.has("onProgress"))
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
if(!obj.has("nextRequest"))
@@ -31,6 +33,7 @@ class JSPlaybackTracker: IPlaybackTracker {
}
override fun onInit(seconds: Double) {
warnIfMainThread("JSPlaybackTracker.onInit");
synchronized(_obj) {
if(_hasCalledInit)
return;
@@ -44,6 +47,7 @@ class JSPlaybackTracker: IPlaybackTracker {
}
override fun onProgress(seconds: Double, isPlaying: Boolean) {
warnIfMainThread("JSPlaybackTracker.onProgress");
synchronized(_obj) {
if(!_hasCalledInit && _hasInit)
onInit(seconds);
@@ -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) };
@@ -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;
}
}
@@ -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;
@@ -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);
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.constructs
import android.provider.Settings.Global
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.*
@@ -39,8 +40,7 @@ class BatchedTaskHandler<TParameter, TResult> {
//Cached
if(result != null)
//TODO: Replace with some kind of constant Deferred<IPlatformStreamVideo>
return _scope.async { result as TResult }
return CompletableDeferred(result as TResult);
//Already requesting
if(taskResult != null)
return taskResult as Deferred<TResult>;
@@ -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
@@ -5,6 +5,16 @@ import com.google.android.exoplayer2.util.Log
class Stopwatch {
var startTime = System.nanoTime()
val elapsedMs: Double get() {
val now = System.nanoTime()
val diff = now - startTime
return diff / 1000000.0
}
fun reset() {
startTime = System.nanoTime()
}
fun logAndNext(tag: String, message: String): Long {
val now = System.nanoTime()
val diff = now - startTime
@@ -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);
}
@@ -18,24 +18,39 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets
import kotlinx.coroutines.*
class V8Plugin {
val config: IV8PluginConfig;
private val _client: ManagedHttpClient;
private val _clientAuth: ManagedHttpClient;
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>();
constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) {
this._client = client;
this._clientAuth = clientAuth;
@@ -78,7 +93,7 @@ class V8Plugin {
fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(this) {
synchronized(_runtimeLock) {
if (_runtime != null)
return;
@@ -118,32 +133,79 @@ 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 {
return executeTyped<V8Value>(js);
}
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) {
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
open class PluginEngineException(config: IV8PluginConfig, error: String, code: String? = null) : PluginException(config, error, null, code) {
}
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
class PluginEngineStoppedException(config: IV8PluginConfig, error: String, code: String? = null) : PluginEngineException(config, error, code) {
}
@@ -0,0 +1,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;
}
}
@@ -84,8 +84,8 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context).apply {
headerView.addView(AnnouncementView(context))
_announcementsView = AnnouncementView(context, null).apply {
headerView.addView(this);
};
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
@@ -110,7 +110,10 @@ class HomeFragment : MainFragment() {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, {
loadResults()
});
}) {
finishRefreshLayoutLoader();
setLoading(false);
};
};
}
@@ -32,7 +32,7 @@ class ImportSubscriptionsFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack);
super.onShownWithView(parameter, isBack);
_view?.onShown(parameter, isBack);
}
@@ -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) {
@@ -185,7 +185,7 @@ class SourceDetailFragment : MainFragment() {
val config = _config;
if (config != null) {
_sourceHeader.loadConfig(config);
_sourceHeader.loadConfig(config, StatePlugins.instance.getScript(config.id));
} else {
_sourceHeader.clear();
}
@@ -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
@@ -124,8 +125,8 @@ class SubscriptionsFeedFragment : MainFragment() {
if (announcementsView == null && !isHomeEnabled) {
val c = context;
if (c != null) {
_announcementsView = AnnouncementView(c).apply {
headerView?.addView(AnnouncementView(c))
_announcementsView = AnnouncementView(c, null).apply {
headerView?.addView(this)
};
}
}
@@ -168,7 +169,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 +257,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>) {
@@ -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();
@@ -94,11 +94,13 @@ import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
import com.google.common.base.Stopwatch
import com.google.protobuf.ByteString
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
@@ -231,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);
@@ -490,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")
@@ -596,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) {
@@ -659,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"}");
@@ -693,7 +705,7 @@ class VideoDetailView : ConstraintLayout {
_player.updateRotateLock();
}
fun onPause() {
Logger.i(TAG, "onPause");
Logger.v(TAG, "onPause");
_onPauseCalled = true;
_taskLoadVideo.cancel();
@@ -721,6 +733,8 @@ class VideoDetailView : ConstraintLayout {
_overlay_quality_selector?.hide();
_retryJob?.cancel();
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_taskLoadVideo.cancel();
handleStop();
_didStop = true;
@@ -807,6 +821,8 @@ class VideoDetailView : ConstraintLayout {
_retryJob?.cancel();
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_retryCount = 0;
fetchVideo();
@@ -896,6 +912,8 @@ class VideoDetailView : ConstraintLayout {
_retryJob?.cancel();
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_retryCount = 0;
fetchVideo();
}
@@ -926,16 +944,26 @@ class VideoDetailView : ConstraintLayout {
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
this.video = video;
this._playbackTracker = null;
if(video is JSVideoDetails) {
val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val tracker = video.getPlaybackTracker() ?: StatePlatform.instance.getPlaybackTracker(video.url);
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker()
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
if (tracker == null) {
stopwatch.reset()
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
Logger.i(TAG, "StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
}
if(me.video == video)
me._playbackTracker = tracker;
}
catch(ex: Throwable) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to get Playback Tracker", ex);
};
}
@@ -991,6 +1019,7 @@ class VideoDetailView : ConstraintLayout {
_subTitle.text = subTitleSegments.joinToString("");
_rating.onLikeDislikeUpdated.remove(this);
if (ref != null) {
_rating.visibility = View.GONE;
@@ -1022,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) {
@@ -1075,12 +1104,14 @@ class VideoDetailView : ConstraintLayout {
_layoutRating.visibility = View.GONE;
}
//Overlay
updateQualitySourcesOverlay(video);
setLoading(false);
//Set Mediasource
val toResume = _videoResumePositionMilliseconds;
_videoResumePositionMilliseconds = 0;
loadCurrentVideo(toResume);
@@ -1108,6 +1139,7 @@ class VideoDetailView : ConstraintLayout {
_textResume.text = "";
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
StatePlayer.instance.setCurrentlyPlaying(video);
@@ -1115,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();
}
@@ -1244,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 }
@@ -1329,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;
@@ -1946,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) {
@@ -2065,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);
}
}
@@ -2075,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});
@@ -2092,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 {
@@ -2108,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)
@@ -14,6 +14,8 @@ class OrientationManager : OrientationEventListener {
constructor(context: Context) : super(context) { }
//TODO: Something weird is going on here
//TODO: Old implementation felt pretty good for me, but now with 0 deadzone still feels bad, even though code should be identical?
override fun onOrientationChanged(orientationAnglep: Int) {
if (orientationAnglep == -1) return
@@ -1,8 +1,13 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@kotlinx.serialization.Serializable
class HistoryVideo {
@@ -18,4 +23,41 @@ class HistoryVideo {
this.position = position;
this.date = date;
}
fun toReconString(): String {
return "${video.url}|||${date.toEpochSecond()}|||${position}|||${video.name}";
}
companion object {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo {
var index = str.indexOf("|||");
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
val url = str.substring(0, index);
var indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val dateSec = str.substring(index + 3, indexNext).toLong();
index = indexNext;
indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val position = str.substring(index + 3, indexNext).toLong();
val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
id = PlatformID.asUrlID(url),
name = name,
thumbnails = Thumbnails(),
author = PlatformAuthorLink(PlatformID.NONE, "Unknown", ""),
datetime = null,
url = url,
shareUrl = url,
duration = 0,
viewCount = -1
);
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
}
}
}
@@ -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())
@@ -2,7 +2,9 @@ package com.futo.platformplayer.states
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioManager
@@ -10,12 +12,20 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting
@@ -42,7 +52,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();
@@ -52,6 +64,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
@@ -158,6 +221,32 @@ class StateApp {
return state;
}
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, "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
.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) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
}
//Lifecycle
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
_context = context;
@@ -260,7 +349,7 @@ class StateApp {
}
Logger.onLogSubmitted.subscribe {
scopeGetter().launch(Dispatchers.Main) {
scopeOrNull?.launch(Dispatchers.Main) {
try {
if (it != null) {
UIDialogs.toast("Uploaded " + (it ?: "null"), true);
@@ -338,16 +427,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 {
@@ -471,7 +576,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)
@@ -1,17 +1,33 @@
package com.futo.platformplayer.states
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract.EXTRA_INITIAL_URI
import androidx.activity.ComponentActivity
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
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
@@ -22,6 +38,9 @@ import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
@@ -34,12 +53,22 @@ class StateBackup {
private val _autoBackupLock = Object();
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(
@@ -56,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();
@@ -72,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");
}
@@ -97,32 +134,51 @@ class StateBackup {
}
}
}
//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())
if (backupFiles.first?.exists() != true)
throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = 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");
} catch (ex: Throwable) {
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(SettingsActivity.getActivity() != null)
SettingsActivity.getActivity();
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
if(activity != null) {
if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
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");
@@ -156,7 +212,8 @@ class StateBackup {
);
val storesToSave = getAllMigrationStores()
.associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() };
.mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap();
val settings = Settings.instance.encode();
val pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id }
@@ -166,7 +223,12 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
return ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
//export.videoCache = StatePlaylists.instance.getHistory()
// .distinctBy { it.video.url }
// .map { it.video };
return export;
}
@@ -342,6 +404,7 @@ class StateBackup {
val plugins: Map<String, String>,
val pluginSettings: Map<String, Map<String, String?>>,
) {
var videoCache: List<SerializedPlatformVideo>? = null;
fun asZip(): ByteArray {
return ByteArrayOutputStream().use { byteStream ->
@@ -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 } });
@@ -8,6 +8,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformClientPool
import com.futo.platformplayer.api.media.PlatformMultiClientPool
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.FilterGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -30,6 +31,7 @@ import com.futo.platformplayer.awaitFirstNotNullDeferred
import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fromPool
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
@@ -38,6 +40,7 @@ import com.futo.platformplayer.stores.*
import kotlinx.coroutines.*
import okhttp3.internal.concat
import java.time.OffsetDateTime
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
import kotlin.streams.toList
/***
@@ -45,7 +48,7 @@ import kotlin.streams.toList
*/
class StatePlatform {
private val TAG = "StatePlatform";
private val VIDEO_CACHE = 1024 * 1024 * 10;
private val VIDEO_CACHE = 100;
private val _scope = CoroutineScope(Dispatchers.IO);
@@ -61,7 +64,18 @@ class StatePlatform {
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
//ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video
//It also allows parallel usage of plugins that would otherwise be impossible.
//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("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");
private var _primaryClientObj : IPlatformClient? = null;
@@ -83,14 +97,16 @@ class StatePlatform {
private val _batchTaskGetVideoDetails: BatchedTaskHandler<String, IPlatformContentDetails> = BatchedTaskHandler<String, IPlatformContentDetails>(_scope,
{ url ->
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
_enabledClients.find { it.isContentDetailsUrl(url) }?.getContentDetails(url)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url)
} ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
},
{
if(!Settings.instance.browsing.videoCache)
return@BatchedTaskHandler null;
else {
val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null;
Logger.i(TAG, "Video Cache Hit [${cached.video.name}]");
if (cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
Logger.i(TAG, "Invalidated cache for [${it}]");
synchronized(_cache) {
@@ -231,21 +247,7 @@ class StatePlatform {
fun getClient(id: String): IPlatformClient {
return getClientOrNull(id) ?: throw IllegalArgumentException("Client with id $id does not exist");
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity);
}
fun getClientsByClaimType(claimType: Int): List<IPlatformClient> {
return getEnabledClients().filter { it.isClaimTypeSupported(claimType) };
}
@@ -373,7 +375,7 @@ class StatePlatform {
synchronized(clientIdsOngoing) {
clientIdsOngoing.add(it.id);
}
val homeResult = it.getHome();
val homeResult = it.fromPool(_pagerClientPool).getHome();
synchronized(clientIdsOngoing) {
clientIdsOngoing.remove(it.id);
}
@@ -393,17 +395,19 @@ class StatePlatform {
val deferred: List<Pair<IPlatformClient, Deferred<IPager<IPlatformContent>?>>> = clients.map {
return@map Pair(it, scope.async(Dispatchers.IO) {
try {
val searchResult = it.getHome();
val searchResult = it.fromPool(_pagerClientPool).getHome();
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 },
@@ -572,6 +576,7 @@ class StatePlatform {
pagers.put(it.searchChannels(query), 1f);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed search channels", ex)
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
}
};
@@ -604,7 +609,12 @@ class StatePlatform {
}
fun getPlaybackTracker(url: String): IPlaybackTracker? {
return getContentClientOrNull(url)?.getPlaybackTracker(url);
val baseClient = getContentClientOrNull(url) ?: return null;
if (baseClient !is JSClient) {
return baseClient.getPlaybackTracker(url);
}
val client = _trackerClientPool.getClientPooled(baseClient, 1);
return client.getPlaybackTracker(url);
}
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
@@ -627,7 +637,7 @@ class StatePlatform {
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
getClientPooled(baseClient, usePooledClients);
_channelClientPool.getClientPooled(baseClient, usePooledClients);
else baseClient;
var lastStream: OffsetDateTime? = null;
@@ -778,7 +788,7 @@ class StatePlatform {
if(!client.capabilities.hasGetComments)
return EmptyPager();
return client.getComments(url);
return client.fromPool(_mainClientPool).getComments(url);
}
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
Logger.i(TAG, "Platform - getSubComments");
@@ -789,7 +799,7 @@ class StatePlatform {
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? {
Logger.i(TAG, "Platform - getLiveChat");
var client = getContentClient(url);
return client.getLiveEvents(url);
return client.fromPool(_liveEventClientPool).getLiveEvents(url);
}
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? {
Logger.i(TAG, "Platform - getLiveChat");
@@ -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
@@ -4,6 +4,7 @@ import android.content.Context
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.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -38,6 +39,11 @@ class StatePlaylists {
})
.load();
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
.withRestore(object: ReconstructStore<HistoryVideo>() {
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
= HistoryVideo.fromReconString(backup, null);
})
.load();
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup())
@@ -49,7 +55,7 @@ class StatePlaylists {
val onWatchLaterChanged = Event0();
fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore);
return listOf(playlistStore, _watchlistStore, _historyStore);
}
fun getWatchLater() : List<SerializedPlatformVideo> {
@@ -122,6 +128,11 @@ class StatePlaylists {
}
if (shouldUpdate) {
//A unrecovered item
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
historyVideo.video = SerializedPlatformVideo.fromVideo(video);
historyVideo.position = pos;
historyVideo.date = OffsetDateTime.now();
_historyStore.saveAsync(historyVideo);
@@ -27,12 +27,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 +61,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 +118,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();
@@ -177,18 +176,16 @@ class StatePlugins {
}
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
val config: SourcePluginConfig;
try {
val configResp = ManagedHttpClient().get(sourceUrl);
val configResp = client.get(sourceUrl);
if(!configResp.isOk)
throw IllegalStateException("Failed request with ${configResp.code}");
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
val config = SourcePluginConfig.fromJson(configJson, sourceUrl);
withContext(Dispatchers.Main) {
installPlugin(context, scope, config, handler);
}
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
}
catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
@@ -199,8 +196,8 @@ class StatePlugins {
finish();
handler?.invoke(false);
}, UIDialogs.ActionStyle.PRIMARY));
};
return@launch;
}
catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
@@ -209,13 +206,36 @@ class StatePlugins {
handler?.invoke(false);
});
};
return@launch;
}
val script: String?
try {
val scriptResp = client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to fetch script", ex);
};
return@launch;
}
withContext(Dispatchers.Main) {
installPlugin(context, scope, config, script, handler);
}
}
}
fun installPlugin(context: Context, scope: CoroutineScope, config: SourcePluginConfig, handler: ((Boolean)->Unit)? = null) {
fun installPlugin(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, handler: ((Boolean)->Unit)? = null) {
val client = ManagedHttpClient();
val warnings = config.getWarnings();
if (script.isEmpty())
throw IllegalStateException("script empty");
fun doInstall(reinstall: Boolean) {
UIDialogs.showDialogProgress(context) {
@@ -224,13 +244,6 @@ class StatePlugins {
scope.launch(Dispatchers.IO) {
try {
val scriptResp = client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
val script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
withContext(Dispatchers.Main) {
it.setText("Validating script...");
it.setProgress(0.25);
@@ -408,6 +421,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
@@ -67,7 +67,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)) {
@@ -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) {
@@ -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) { }
@@ -5,6 +5,8 @@ import android.util.AttributeSet
import android.view.View
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
@@ -14,6 +16,9 @@ import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanNowDiffString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AnnouncementView : LinearLayout {
private val _root: ConstraintLayout;
@@ -28,9 +33,13 @@ class AnnouncementView : LinearLayout {
private val _category: String?;
private var _currentAnnouncement: Announcement? = null;
private val _scope: CoroutineScope?;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_announcement, this);
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull; //TODO: Fetch correct scope
val dp10 = 10.dp(resources);
setPadding(dp10, dp10, dp10, dp10);
@@ -69,31 +78,30 @@ class AnnouncementView : LinearLayout {
}
override fun onAttachedToWindow() {
Logger.i(TAG, "onAttachedToWindow");
super.onAttachedToWindow()
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
refresh();
_scope?.launch(Dispatchers.Main) {
refresh();
}
}
refresh();
}
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) {
@@ -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);
}
@@ -23,6 +23,7 @@ class SourceHeaderView : LinearLayout {
private val _sourceVersion: TextView;
private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView;
private val _sourceSignature: TextView;
private var _config : SourcePluginConfig? = null;
@@ -38,6 +39,7 @@ class SourceHeaderView : LinearLayout {
_sourceVersion = findViewById(R.id.source_version);
_sourceRepositoryUrl = findViewById(R.id.source_repo);
_sourceScriptUrl = findViewById(R.id.source_script);
_sourceSignature = findViewById(R.id.source_signature);
_sourceBy.setOnClickListener {
if(!_config?.authorUrl.isNullOrEmpty())
@@ -53,7 +55,7 @@ class SourceHeaderView : LinearLayout {
};
}
fun loadConfig(config: SourcePluginConfig) {
fun loadConfig(config: SourcePluginConfig, script: String?) {
_config = config;
val loadedIcon = StatePlugins.instance.getPluginIconOrNull(config.id);
@@ -76,6 +78,22 @@ class SourceHeaderView : LinearLayout {
_sourceBy.setTextColor(resources.getColor(R.color.colorPrimary));
else
_sourceBy.setTextColor(Color.WHITE);
if (!config.scriptPublicKey.isNullOrEmpty() && !config.scriptSignature.isNullOrEmpty()) {
if (script == null) {
_sourceSignature.setTextColor(Color.rgb(0xAC, 0xAC, 0xAC));
_sourceSignature.text = "Script is not available";
} else if (config.validate(script)) {
_sourceSignature.setTextColor(Color.rgb(0, 255, 0));
_sourceSignature.text = "Signature is valid";
} else {
_sourceSignature.setTextColor(Color.rgb(255, 0, 0));
_sourceSignature.text = "Signature is invalid";
}
} else {
_sourceSignature.setTextColor(Color.rgb(255, 0, 0));
_sourceSignature.text = "No signature available";
}
}
fun clear() {
@@ -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;
@@ -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"
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
@@ -144,4 +144,27 @@
android:fontFamily="@font/inter_extra_light"
android:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout>
<!--Script Url-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/white"
android:layout_marginTop="10dp"
android:fontFamily="@font/inter_light"
android:text="Signature" />
<TextView
android:id="@+id/source_signature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/colorPrimary"
android:fontFamily="@font/inter_extra_light"
android:text="Valid" />
</LinearLayout>
</LinearLayout>
+1 -1
View File
@@ -3,7 +3,7 @@ DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
bash ./sign-all-sources.sh
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
+1 -1
View File
@@ -3,7 +3,7 @@ DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
bash ./sign-all-sources.sh
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
+1 -1
View File
@@ -3,7 +3,7 @@ DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
bash ./sign-all-sources.sh
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."

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