From 7fe7f7537b73f48d7467447700d3f9f6c34aceb4 Mon Sep 17 00:00:00 2001 From: bermount <127654770+bermount@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:34:16 +0900 Subject: [PATCH 1/3] feat: implement "Resume All" functionality for downloads Adds a "Resume All" button to the downloads screen that allows users to simultaneously resume all incomplete downloads. --- .../github/libretube/db/obj/DownloadItem.kt | 7 ++- .../libretube/helpers/DownloadHelper.kt | 2 +- .../libretube/services/DownloadService.kt | 40 ++++++++++++- .../ui/fragments/DownloadsFragment.kt | 17 ++++++ .../res/layout/fragment_download_content.xml | 57 ++++++++++++------- app/src/main/res/values/strings.xml | 1 + 6 files changed, 100 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt index a04c2631fa..a2ff33997d 100644 --- a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt @@ -2,10 +2,12 @@ package com.github.libretube.db.obj import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import com.github.libretube.enums.FileType import java.nio.file.Path +import kotlin.io.path.fileSize @Entity( tableName = "downloadItem", @@ -31,4 +33,7 @@ data class DownloadItem( var quality: String? = null, var language: String? = null, var downloadSize: Long = -1L -) +) { + @Ignore + val isFinished get() = runCatching { path.fileSize() }.getOrDefault(0L) < downloadSize +} diff --git a/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt b/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt index 8639b504d1..c91c58d934 100644 --- a/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt @@ -171,4 +171,4 @@ object DownloadHelper { DatabaseHolder.Database.downloadDao().deleteDownload(download) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index dc2b61dbae..6587f3101f 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -17,6 +17,7 @@ import androidx.core.app.NotificationCompat.Builder import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat import androidx.core.content.getSystemService +import androidx.core.util.contains import androidx.core.util.keyIterator import androidx.core.util.set import androidx.core.util.valueIterator @@ -140,6 +141,7 @@ class DownloadService : LifecycleService() { ACTION_DOWNLOAD_RESUME -> resume(downloadId!!) ACTION_DOWNLOAD_PAUSE -> pause(downloadId!!) ACTION_DOWNLOAD_STOP -> stop(downloadId!!) + ACTION_RESUME_ALL -> resumeAll() } registerNetworkChangedCallback() @@ -499,6 +501,40 @@ class DownloadService : LifecycleService() { stopServiceIfDone() } + /** + * Resume all downloads: Queue them all, then fill empty slots. + */ + private fun resumeAll() { + lifecycleScope.launch(coroutineContext) { + val incompleteItems = withContext(Dispatchers.IO) { + Database.downloadDao().getAll() + .flatMap { it.downloadItems } + .filter { !it.isFinished } + } + + incompleteItems.forEach { + if (!downloadQueue.contains(it.id)) { + downloadQueue.put(it.id, false) + } + } + + val max = DownloadHelper.getMaxConcurrentDownloads() + val current = downloadQueue.valueIterator().asSequence().count { it } + val slotsToFill = max - current + + if (slotsToFill > 0) { + val candidates = incompleteItems.filter { !downloadQueue[it.id] } + .take(slotsToFill) + + candidates.forEach { item -> + launch { + downloadFile(item) + } + } + } + } + } + /** * Stop downloading job for given [id]. If no downloads are active, stop the service. */ @@ -696,6 +732,8 @@ class DownloadService : LifecycleService() { "com.github.libretube.services.DownloadService.ACTION_SERVICE_STARTED" const val ACTION_SERVICE_STOPPED = "com.github.libretube.services.DownloadService.ACTION_SERVICE_STOPPED" + const val ACTION_RESUME_ALL = + "com.github.libretube.services.DownloadService.ACTION_RESUME_ALL" // any values that are not in that range are strictly rate limited by YT or are very slow due // to the amount of requests that's being made @@ -704,4 +742,4 @@ class DownloadService : LifecycleService() { var IS_DOWNLOAD_RUNNING = false } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt index a77e252a84..bf0de5cfd7 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt @@ -232,6 +232,7 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment(R.layout.fragment_dow } binding.playlistName.text = playlist.downloadPlaylist.title + binding.playlistName.isVisible = true playlist.downloadVideos.map { it.videoId } } @@ -285,6 +286,18 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment(R.layout.fragment_dow toggleVisibilities() } + binding.resumeAll.setOnClickListener { + val intent = Intent(requireContext(), DownloadService::class.java) + intent.action = DownloadService.ACTION_RESUME_ALL + requireContext().startService(intent) + } + + binding.resumeAll.setOnClickListener { + val intent = Intent(requireContext(), DownloadService::class.java) + intent.action = DownloadService.ACTION_RESUME_ALL + requireContext().startService(intent) + } + binding.deleteAll.setOnClickListener { showDeleteAllDialog(binding.root.context, adapter) } @@ -328,9 +341,13 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment(R.layout.fragment_dow val binding = _binding ?: return val isEmpty = adapter.itemCount == 0 + val hasIncomplete = adapter.currentList.any { download -> + download.downloadItems.any { !it.isFinished } + } binding.downloadsEmpty.isVisible = isEmpty binding.downloadsContainer.isGone = isEmpty binding.deleteAll.isGone = isEmpty + binding.resumeAll.isVisible = hasIncomplete binding.shuffleAll.isGone = isEmpty } diff --git a/app/src/main/res/layout/fragment_download_content.xml b/app/src/main/res/layout/fragment_download_content.xml index 2421e9c16d..bcb36d9628 100644 --- a/app/src/main/res/layout/fragment_download_content.xml +++ b/app/src/main/res/layout/fragment_download_content.xml @@ -12,44 +12,64 @@ android:layout_height="match_parent" android:orientation="vertical"> - + android:paddingHorizontal="8dp"> - + android:text="@string/resume_all" + android:contentDescription="@string/resume_all" + app:icon="@drawable/ic_play" + android:tooltipText="@string/resume_all" + android:visibility="gone" + tools:targetApi="o" + tools:visibility="visible" /> - + + + + android:orientation="vertical" /> + + @@ -110,9 +130,4 @@ tools:targetApi="o" tools:visibility="visible" /> - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6868b743d2..23aaab10fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -547,6 +547,7 @@ Showing results for: Fast forward on long press Play video with double speed while tapping and holding the screen. + Resume Downloads Download Service From e3e90bf532582a384eac3b71b62813533fb01f3d Mon Sep 17 00:00:00 2001 From: FirthR5 <47197502+FirthR5@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:58:31 +0200 Subject: [PATCH 2/3] fix: update playlist info after removing a video --- .../ui/fragments/PlaylistFragment.kt | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt index cc2278ec2e..96f51ed391 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt @@ -149,13 +149,8 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist playlistName = response.name isLoading = false - if (!response.thumbnailUrl.isNullOrEmpty()) { - ImageHelper.loadImage(response.thumbnailUrl, binding.thumbnail) - } else { - binding.thumbnail.setImageResource(R.drawable.ic_empty_playlist) - binding.thumbnail.setPadding(64f.dpToPx()) - binding.thumbnail.setBackgroundColor(com.google.android.material.R.attr.colorSurface) - } + setPlaylistThumbnail(response.thumbnailUrl) + binding.playlistProgress.isGone = true binding.playlistAppBar.isVisible = true binding.playlistRecView.isVisible = true @@ -186,7 +181,8 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist ) } - binding.playlistInfo.text = getChannelAndVideoString(response, playlistFeed.size) + binding.playlistInfo.text = + getChannelAndVideoString(response, playlistFeed.size) } }) @@ -388,9 +384,10 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist playlistAdapter?.submitList(videos) - updatePlaylistDuration() + updatePlaylistDuration(videos) } + @SuppressLint("StringFormatInvalid") private fun removeFromPlaylist(sortedFeedPosition: Int) { val playlistAdapter = playlistAdapter ?: return @@ -423,6 +420,7 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist ) } .show() + updateInfo(updatedList) } } catch (e: Exception) { Log.e(TAG(), e.toString()) @@ -431,6 +429,16 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist } } + private fun updateInfo(updatedList: List) { + val playlistCount = updatedList.size + binding.playlistInfo.text = getChannelAndVideoString( + Playlist(name = playlistName, videos = playlistCount), + playlistCount + ) + updatePlaylistDuration(updatedList) + setPlaylistThumbnail(updatedList.firstOrNull()?.item?.thumbnail) + } + private fun reAddToPlaylist( streamItem: StreamItem, sortedFeedPosition: Int, @@ -447,6 +455,7 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist withContext(Dispatchers.Main) { playlistAdapter.submitList(fixedList) + updateInfo(fixedList) } } catch (e: Exception) { Log.e(TAG(), e.toString()) @@ -464,7 +473,11 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist * * I.e., this method adds the given offset to all videos with an originalPlaylistIndex > modifiedPosition. */ - private fun fixItemIndices(items: List, modifiedPosition: Int, offset: Int): List { + private fun fixItemIndices( + items: List, + modifiedPosition: Int, + offset: Int + ): List { return items.map { if (it.originalPlaylistIndex > modifiedPosition) { it.copy(originalPlaylistIndex = it.originalPlaylistIndex + offset) @@ -504,18 +517,30 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist PlaylistItem(item, currentList.size + index) } playlistAdapter?.submitList(newList) - updatePlaylistDuration() + updatePlaylistDuration(newList) isLoading = false } } @SuppressLint("SetTextI18n") - private fun updatePlaylistDuration() { - val totalDuration = playlistFeed.sumOf { it.duration ?: 0 } ?: return + private fun updatePlaylistDuration(updatedList: List) { + val totalDuration = updatedList.sumOf { it.item.duration ?: 0 } binding.playlistDuration.text = DateUtils.formatElapsedTime(totalDuration) + if (nextPage != null) "+" else "" } + // Update the Cover/Thumbnail of the playlist if the first video was removed + private fun setPlaylistThumbnail(thumbnailUrl: String?) { + if (!thumbnailUrl.isNullOrEmpty()) { + ImageHelper.loadImage(thumbnailUrl, binding.thumbnail) + } else { + binding.thumbnail.setImageResource(R.drawable.ic_empty_playlist) + binding.thumbnail.setPadding(64f.dpToPx()) + binding.thumbnail.setBackgroundColor(com.google.android.material.R.attr.colorSurface) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // manually restore the recyclerview state due to https://github.com/material-components/material-components-android/issues/3473 From 3de36d2d4045512f071d9107bc29349798a9b0ad Mon Sep 17 00:00:00 2001 From: FirthR5 <47197502+FirthR5@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:59:37 +0200 Subject: [PATCH 3/3] fix: isFinished annotation on DownloadItem --- app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt index a2ff33997d..c68ac0558e 100644 --- a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt @@ -2,7 +2,6 @@ package com.github.libretube.db.obj import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import com.github.libretube.enums.FileType @@ -34,6 +33,5 @@ data class DownloadItem( var language: String? = null, var downloadSize: Long = -1L ) { - @Ignore val isFinished get() = runCatching { path.fileSize() }.getOrDefault(0L) < downloadSize }