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.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -28,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@ -43,7 +45,7 @@ class UISlideOverlays {
menu.show(); 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>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@ -121,6 +123,8 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
} }
//ContentResolver is required for subtitles..
if(contentResolver != null) {
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
@ -133,6 +137,7 @@ class UISlideOverlays {
} }
}, false); }, false);
})); }));
}
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items); menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items);
@ -157,29 +162,12 @@ class UISlideOverlays {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val subtitleUri = subtitleToDownload.getSubtitlesURI(); val subtitleUri = subtitleToDownload.getSubtitlesURI();
if (subtitleUri != null) { //TODO: Remove uri dependency, should be able to work with raw aswell?
var subtitles: String? = null; if (subtitleUri != null && contentResolver != null) {
if ("file" == subtitleUri.scheme) { val subtitlesRaw = StateDownloads.instance.downloadSubtitles(subtitleToDownload, contentResolver);
val inputStream = contentResolver.openInputStream(subtitleUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
subtitles = reader.use { it.readText() };
}
} else if ("http" == subtitleUri.scheme || "https" == subtitleUri.scheme) {
val client = ManagedHttpClient();
val subtitleResponse = client.get(subtitleUri.toString());
if (!subtitleResponse.isOk) {
throw Exception("Cannot fetch subtitles from source '${subtitleUri}': ${subtitleResponse.code}");
}
subtitles = subtitleResponse.body?.toString()
?: throw Exception("Subtitles are invalid '${subtitleUri}': ${subtitleResponse.code}");
} else {
throw Exception("Unsuported scheme");
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateDownloads.instance.download(video, selectedVideo, selectedAudio, if (subtitles != null) SubtitleRawSource(subtitleToDownload.name, subtitleToDownload.format, subtitles!!) else null); StateDownloads.instance.download(video, selectedVideo, selectedAudio, subtitlesRaw);
} }
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -195,10 +183,41 @@ class UISlideOverlays {
}; };
return menu.apply { show() }; return menu.apply { show() };
} }
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup) { fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
val handleUnknownDownload: ()->Unit = {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload("Video", container) { px, bitrate ->
StateDownloads.instance.download(video, 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) { fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload("Video", container) { px, bitrate ->
@ -273,6 +292,18 @@ class UISlideOverlays {
menu.show(); 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 { fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
@ -295,7 +326,7 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide", SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }), { StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container); }, false) { showDownloadVideoOverlay(video, container, true); }, false)
)) ))
items.add( items.add(
SlideUpMenuGroup(container.context, "Add To", "addto", 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", SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container); }, false)) { showDownloadVideoOverlay(video, container, true); }, false))
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); 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.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -30,7 +31,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.CancellationException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
@ -371,7 +371,7 @@ class VideoDownload {
} }
if (isCancelled) if (isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled");
} while (read > 0); } while (read > 0);
lastSpeed = 0; lastSpeed = 0;
@ -423,7 +423,7 @@ class VideoDownload {
} }
if(isCancelled) if(isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled", null);
} }
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
} }

View File

@ -608,7 +608,7 @@ class VideoDetailView : ConstraintLayout {
}, },
RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) {
video?.let { 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) { 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.StateAnnouncement
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -138,18 +139,7 @@ class DownloadService : Service() {
else if(ex is DownloadException && !ex.isRetryable) { else if(ex is DownloadException && !ex.isRetryable) {
Logger.w(TAG, "Video had exception that should not be retried"); Logger.w(TAG, "Video had exception that should not be retried");
StateDownloads.instance.removeDownload(currentVideo); StateDownloads.instance.removeDownload(currentVideo);
//Ensure impossible downloads are not retried for playlists StateDownloads.instance.preventPlaylistDownload(currentVideo);
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}");
}
}
}
}
} }
else else
Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex); Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex);
@ -157,6 +147,7 @@ class DownloadService : Service() {
currentVideo.changeState(VideoDownload.State.ERROR); currentVideo.changeState(VideoDownload.State.ERROR);
ignore.add(currentVideo); ignore.add(currentVideo);
if(ex !is CancellationException)
StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload", StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload",
"Download failed", "Download failed",
"Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download"); "Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download");

View File

@ -1,12 +1,16 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.ContentResolver
import android.net.Uri
import android.os.StatFs import android.os.StatFs
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException
import com.futo.platformplayer.api.media.models.streams.sources.* 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.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
@ -147,6 +151,19 @@ class StateDownloads {
_downloading.delete(download); _downloading.delete(download);
onDownloadsChanged.emit(); 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() { fun checkForDownloadsTodos() {
val hasPlaylistChanged = checkForOutdatedPlaylists(); 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> { fun cleanupDownloads(): Pair<Int, Long> {
val expected = getDownloadedVideos(); val expected = getDownloadedVideos();
val validFiles = HashSet(expected.flatMap { it.videoSource.map { it.filePath } + it.audioSource.map { it.filePath } }); 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.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import com.futo.platformplayer.R import com.futo.platformplayer.R
class Loader : LinearLayout { class Loader : LinearLayout {
@ -15,7 +17,7 @@ class Loader : LinearLayout {
private val _animatable: Animatable; private val _animatable: Animatable;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { 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); _imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable; _animatable = _imageLoader.drawable as Animatable;
@ -29,6 +31,18 @@ class Loader : LinearLayout {
visibility = View.GONE; 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() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()

View File

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

View File

@ -40,7 +40,7 @@ class SlideUpMenuOverlay : RelativeLayout {
_groupItems = listOf(); _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); init(animated, okText);
_container = parent; _container = parent;
if(!_container!!.children.contains(this)) { if(!_container!!.children.contains(this)) {
@ -50,6 +50,12 @@ class SlideUpMenuOverlay : RelativeLayout {
_textTitle.text = titleText; _textTitle.text = titleText;
_groupItems = items; _groupItems = items;
if(hideButtons) {
_textCancel.visibility = GONE;
_textOK.visibility = GONE;
_textTitle.textAlignment = TextView.TEXT_ALIGNMENT_CENTER;
}
setItems(items); setItems(items);
} }