Compare commits

..

4 Commits

31 changed files with 472 additions and 124 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());
@@ -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>();
@@ -58,7 +58,7 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
fun warnIfMainThread(context: String) {
if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper())
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace);
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace.joinToString { it.toString() });
}
fun ensureNotMainThread() {
@@ -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) {}
@@ -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;
}
}
@@ -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;
@@ -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,24 +68,31 @@ 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;
@@ -95,7 +103,18 @@ class TaskHandler<TParameter, TResult> {
}
}
}
}
}/*.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
@@ -1,8 +1,19 @@
package com.futo.platformplayer.downloads
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@kotlinx.serialization.Serializable
data class PlaylistDownloadDescriptor(
val id: String,
val targetPxCount: Long?,
val targetBitrate: Long?
);
) {
var preventDownload: MutableList<String> = arrayListOf();
fun getPreventDownloadList(): List<String> = synchronized(preventDownload){ preventDownload };
fun shouldDownload(video: IPlatformVideo): Boolean {
synchronized(preventDownload) {
return !preventDownload.contains(video.url);
}
}
}
@@ -13,12 +13,16 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.hasAnySource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -27,7 +31,6 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.OffsetDateTime
import java.util.concurrent.CancellationException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
@@ -147,27 +150,37 @@ class VideoDownload {
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
if(original.video.hasAnySource() && !original.isDownloadable()) {
Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}");
throw DownloadException("Unsupported video for downloading", false);
}
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) {
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource);
else
throw IllegalStateException("Download video source is not a url source");
// ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource != null) {
if (vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource);
else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
}
if(audioSource == null && targetBitrate != null) {
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
?: if(videoSource != null ) null
else throw IllegalStateException("Could not find a valid audio source for video");
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null)
audioSource = null;
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource);
else
throw IllegalStateException("Download audio source is not a url source");
throw DownloadException("Audio source is not supported for downloading (yet)", false);
}
if(videoSource == null && audioSource == null)
throw DownloadException("No valid sources found for video/audio");
}
}
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
@@ -358,7 +371,7 @@ class VideoDownload {
}
if (isCancelled)
throw IllegalStateException("Cancelled");
throw CancellationException("Cancelled");
} while (read > 0);
lastSpeed = 0;
@@ -410,7 +423,7 @@ class VideoDownload {
}
if(isCancelled)
throw IllegalStateException("Cancelled");
throw CancellationException("Cancelled", null);
}
onProgress(sourceLength, totalRead, 0);
}
@@ -1,7 +1,6 @@
package com.futo.platformplayer.engine
import android.content.Context
import android.os.Looper
import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host
@@ -18,9 +17,7 @@ import com.futo.platformplayer.engine.exceptions.*
import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
import kotlinx.coroutines.*
class V8Plugin {
val config: IV8PluginConfig;
@@ -31,14 +28,29 @@ class V8Plugin {
val httpClient: ManagedHttpClient get() = _client;
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
private val _runtimeLock = Object();
var _runtime : V8Runtime? = null;
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null;
var isStopped = true;
val onStopped = Event1<V8Plugin>();
//TODO: Implement a more universal isBusy system for plugins + JSClient + pooling? TBD if propagation would be beneficial
private val _busyCounterLock = Object();
private var _busyCounter = 0;
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
/**
* Called before a busy counter is about to be removed.
* Is primarily used to prevent additional calls to dead runtimes.
*
* Parameter is the busy count after this execution
*/
val afterBusy = Event1<Int>();
constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) {
this._client = client;
this._clientAuth = clientAuth;
@@ -81,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;
@@ -121,19 +133,25 @@ class V8Plugin {
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
}
}
}
fun stop(){
Logger.i(TAG, "Stopping plugin [${config.name}]");
synchronized(this) {
_runtime?.let {
_runtime = null;
if(!it.isClosed && !it.isDead)
it.close();
};
isStopped = true;
whenNotBusy {
synchronized(_runtimeLock) {
isStopped = true;
_runtime?.let {
_runtime = null;
if(!it.isClosed && !it.isDead)
it.close();
Logger.i(TAG, "Stopped plugin [${config.name}]");
};
}
onStopped.emit(this);
}
onStopped.emit(this);
}
fun execute(js: String) : V8Value {
@@ -141,14 +159,53 @@ class V8Plugin {
}
fun <T : V8Value> executeTyped(js: String) : T {
warnIfMainThread("V8Plugin.executeTyped");
if(isStopped)
throw PluginEngineStoppedException(config, "Instance is stopped", js);
synchronized(_busyCounterLock) {
_busyCounter++;
}
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() };
try {
return catchScriptErrors("Plugin[${config.name}]", js) {
runtime.getExecutor(js).execute()
};
}
finally {
synchronized(_busyCounterLock) {
//Free busy *after* afterBusy calls are done to prevent calls on dead runtimes
try {
afterBusy.emit(_busyCounter - 1);
}
catch(ex: Throwable) {
Logger.e(TAG, "Unhandled V8Plugin.afterBusy", ex);
}
_busyCounter--;
}
}
}
fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value };
fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value };
fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value };
fun whenNotBusy(handler: (V8Plugin)->Unit) {
synchronized(_busyCounterLock) {
if(_busyCounter == 0)
handler(this);
else {
val tag = Object();
afterBusy.subscribe(tag) {
if(it == 0) {
Logger.w(TAG, "V8Plugin afterBusy handled");
afterBusy.remove(tag);
handler(this);
}
}
}
}
}
private fun getPackage(context: Context, packageName: String): V8Package {
//TODO: Auto get all package types?
return when(packageName) {
@@ -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;
}
}
@@ -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);
};
};
}
@@ -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
@@ -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>) {
@@ -608,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) {
@@ -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)
@@ -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);
@@ -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 } });
@@ -399,13 +399,15 @@ class StatePlatform {
return@async searchResult;
} catch(ex: Throwable) {
Logger.e(TAG, "getHomeRefresh", ex);
return@async null;
throw ex;
//return@async null;
}
});
}.toList();
val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager();
val toAwait = deferred.filter { it.second != finishedPager.first };
return RefreshDistributionContentPager(
listOf(finishedPager.second),
toAwait.map { it.second },
@@ -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()
@@ -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) { }
@@ -100,7 +100,8 @@ class AnnouncementView : LinearLayout {
}
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;
@@ -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);
}
@@ -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>