Permanently stop playlist video download on cancel, Use detailed video download overlay in overviews

This commit is contained in:
Kelvin 2023-10-13 19:09:07 +02:00
parent 8bb1ff87c0
commit 9ffdf39f13
8 changed files with 146 additions and 60 deletions

View file

@ -17,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
@ -28,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
class UISlideOverlays {
companion object {
@ -43,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;
@ -121,18 +123,21 @@ class UISlideOverlays {
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);
@ -157,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) {
@ -195,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 ->
@ -273,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();
@ -295,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",
@ -348,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>();

View file

@ -22,6 +22,7 @@ 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
@ -30,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
@ -371,7 +371,7 @@ class VideoDownload {
}
if (isCancelled)
throw IllegalStateException("Cancelled");
throw CancellationException("Cancelled");
} while (read > 0);
lastSpeed = 0;
@ -423,7 +423,7 @@ class VideoDownload {
}
if(isCancelled)
throw IllegalStateException("Cancelled");
throw CancellationException("Cancelled", null);
}
onProgress(sourceLength, totalRead, 0);
}

View file

@ -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) {

View file

@ -19,6 +19,7 @@ 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
@ -138,18 +139,7 @@ class DownloadService : Service() {
else if(ex is DownloadException && !ex.isRetryable) {
Logger.w(TAG, "Video had exception that should not be retried");
StateDownloads.instance.removeDownload(currentVideo);
//Ensure impossible downloads are not retried for playlists
if(currentVideo.video != null && currentVideo.groupID != null && currentVideo.groupType == VideoDownload.GROUP_PLAYLIST) {
StateDownloads.instance.getPlaylistDownload(currentVideo.groupID!!)?.let {
synchronized(it.preventDownload) {
if(currentVideo?.video?.url != null && !it.preventDownload.contains(currentVideo!!.video!!.url)) {
it.preventDownload.add(currentVideo!!.video!!.url);
StateDownloads.instance.savePlaylistDownload(it);
Logger.w(TAG, "Preventing further download attempts in playlist [${it.id}] for [${currentVideo?.name}]:${currentVideo?.video?.url}");
}
}
}
}
StateDownloads.instance.preventPlaylistDownload(currentVideo);
}
else
Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex);
@ -157,9 +147,10 @@ class DownloadService : Service() {
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);

View file

@ -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
@ -147,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();
@ -304,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 } });

View file

@ -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()

View file

@ -68,6 +68,7 @@ class ActiveDownloadItem: LinearLayout {
_videoCancel.setOnClickListener {
StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download);
};
_download.onProgressChanged.subscribe(this) {

View file

@ -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);
}