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
}