From 508b567590523d9d7a0422d4206d442ac2595ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20=28LeC-D=29?= Date: Thu, 2 Apr 2026 09:09:46 -0400 Subject: [PATCH 1/5] fix(download): hook downloadDeleteEvent in BaseFetchButton to reset UI on cancel (#1227) --- .../ui/download/button/BaseFetchButton.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 82c4dcc3bed..db28a6d70ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -164,9 +164,11 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } } - /*fun downloadDeleteEvent(data: Int) { - - }*/ + fun downloadDeleteEvent(data: Int) { + if (data == persistentId) { + resetView() + } + } /*fun downloadEvent(data: Pair) { val (id, action) = data @@ -185,7 +187,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onAttachedToWindow() { VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent - // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent + VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent // VideoDownloadManager.downloadEvent += ::downloadEvent VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent @@ -200,7 +202,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onDetachedFromWindow() { VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent - // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent + VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent // VideoDownloadManager.downloadEvent -= ::downloadEvent VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent From 15730b31b97e315d68f34b5393295454587851a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20=28LeC-D=29?= Date: Sat, 27 Jun 2026 19:06:34 -0400 Subject: [PATCH 2/5] fix(download): refresh Downloads list when a download is cancelled (#1227) Issue #1227 reports that cancelled downloads keep showing as episode/movie cards on the Downloads page. The previous change only hooked downloadDeleteEvent in BaseFetchButton, which resets the download *button* on the result page but never touches the Downloads list itself. Observe VideoDownloadManager.downloadDeleteEvent at the activity-scoped DownloadViewModel so both DownloadFragment (headers) and DownloadChildFragment (children) drop the deleted item in real time, with no manual refresh. The handler also keeps multi-select state consistent. Uses thread-safe LiveData postValue since the event fires on the downloader's background thread. Co-Authored-By: Claude Opus 4.8 --- .../ui/download/DownloadViewModel.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 0d35d5670f5..3dd9f245163 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -36,6 +36,7 @@ import com.lagradost.cloudstream3.utils.ResourceLiveData import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadDeleteEvent import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -67,6 +68,17 @@ class DownloadViewModel : ViewModel() { private val _selectedItemIds = ConsistentLiveData?>(null) val selectedItemIds: LiveData?> = _selectedItemIds + init { + // Keep the Downloads list in sync when a download is deleted/cancelled from + // anywhere in the app (result page button, queue, notification, etc.). See + // onDownloadDeleted for the rationale (issue #1227). + downloadDeleteEvent += ::onDownloadDeleted + } + + override fun onCleared() { + downloadDeleteEvent -= ::onDownloadDeleted + super.onCleared() + } fun cancelSelection() { updateSelectedItems { null } @@ -389,6 +401,36 @@ class DownloadViewModel : ViewModel() { postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) } + /** + * Removes a deleted/cancelled download from the currently displayed lists in real time. + * + * Issue #1227: cancelling a download (or deleting it from the result page, the download + * queue, or a notification) fires [VideoDownloadManager.downloadDeleteEvent], but until + * now only the download *button* ([BaseFetchButton]) listened to it. The Downloads page + * itself never reacted, so the deleted episode/movie card stayed on screen until the user + * navigated away and back. We observe the same event here, at the activity-scoped (shared) + * ViewModel level, so both [DownloadFragment] (headers) and [DownloadChildFragment] + * (children) refresh without a manual reload. + * + * The event is invoked synchronously on the downloader's background thread, so this only + * uses thread-safe LiveData postValue. + */ + private fun onDownloadDeleted(id: Int) { + // The id space is shared: movie/series headers use their own id and individual + // episodes use the child id, so filtering both lists by id covers every card type. + // filter() returns null only while the list is still Loading; postHeaders/postChildren + // treat null as "no change", so there is nothing to update in that case. + postHeaders(_headerCards.success?.filter { it.data.id != id }) + postChildren(_childCards.success?.filter { it.data.id != id }) + + // Keep multi-select state consistent: forget the removed id if it was selected. + val currentSelection = selectedItemIds.value + if (currentSelection?.contains(id) == true) { + _selectedItemIds.postValue(currentSelection - id) + updateSelectedBytes() + } + } + private fun updateStorageStats(visual: List) { try { val stat = StatFs(Environment.getExternalStorageDirectory().path) From b051363185bb49cc82dfc15c0a90897e0bf8445d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20=28LeC-D=29?= Date: Tue, 30 Jun 2026 17:18:08 -0400 Subject: [PATCH 3/5] fix(download): rebuild Downloads lists from disk on delete so series episodes vanish (#1227) The previous real-time refresh filtered the in-memory header/child lists by id. That fails for TV shows, as the maintainer noted: _childCards is often in the Loading state when the delete event arrives (deletes triggered from outside the child screen), so filtering a null success list is a no-op. Filtering also can't update a series header when one of its episodes is deleted, since the event carries the child id, not the header id. Instead, rebuild the affected lists from disk in onDownloadDeleted. After a delete the file (and KEY_DOWNLOAD_INFO) is already gone, so getDownloadFileInfo returns null and updateChildList/updateHeaderList naturally drop the episode/movie and recompute header aggregates (count, size). updateChildList gains a pushLoading flag so the live refresh doesn't flicker the list. The application context and the current child folder are retained for the reload; currentChildFolder is cleared when the child screen closes. Co-Authored-By: Claude Opus 4.8 --- .../ui/download/DownloadViewModel.kt | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 3dd9f245163..27ef76c7950 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -68,6 +68,14 @@ class DownloadViewModel : ViewModel() { private val _selectedItemIds = ConsistentLiveData?>(null) val selectedItemIds: LiveData?> = _selectedItemIds + // Retained so onDownloadDeleted can rebuild the lists from disk after a + // delete triggered from anywhere in the app. We only ever store the + // application context (never an Activity) to avoid leaking it past the + // ViewModel's lifetime. currentChildFolder is the folder backing the child + // list currently on screen, or null when no child screen is open. + private var appContext: Context? = null + private var currentChildFolder: String? = null + init { // Keep the Downloads list in sync when a download is deleted/cancelled from // anywhere in the app (result page button, queue, notification, etc.). See @@ -206,6 +214,7 @@ class DownloadViewModel : ViewModel() { } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { + appContext = context.applicationContext // Do not push loading as it interrupts the UI //_headerCards.postValue(Resource.Loading()) @@ -368,8 +377,16 @@ class DownloadViewModel : ViewModel() { }) } - fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { - _childCards.postValue(Resource.Loading()) // always push loading + fun updateChildList( + context: Context, + folder: String, + pushLoading: Boolean = true + ) = viewModelScope.launchSafe { + appContext = context.applicationContext + currentChildFolder = folder + // Push loading when first opening the screen, but not on a live refresh + // (e.g. after a delete) where clearing the list would flicker the UI. + if (pushLoading) _childCards.postValue(Resource.Loading()) val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> @@ -402,7 +419,7 @@ class DownloadViewModel : ViewModel() { } /** - * Removes a deleted/cancelled download from the currently displayed lists in real time. + * Refreshes the Downloads screen in real time when a download is deleted/cancelled. * * Issue #1227: cancelling a download (or deleting it from the result page, the download * queue, or a notification) fires [VideoDownloadManager.downloadDeleteEvent], but until @@ -410,25 +427,35 @@ class DownloadViewModel : ViewModel() { * itself never reacted, so the deleted episode/movie card stayed on screen until the user * navigated away and back. We observe the same event here, at the activity-scoped (shared) * ViewModel level, so both [DownloadFragment] (headers) and [DownloadChildFragment] - * (children) refresh without a manual reload. + * (children) refresh. + * + * We rebuild the lists from disk rather than filtering the in-memory lists by id. Filtering + * cannot handle a series correctly: deleting an *episode* fires the event with the child id, + * which must shrink (or remove) the parent *header* rather than match a header id, and the + * child list is frequently in the [Resource.Loading] state when the delete originates from + * outside the child screen. Reloading reads [VideoDownloadManager.getDownloadFileInfo], + * whose [KEY_DOWNLOAD_INFO] the delete has already cleared, so the gone episode/movie + * disappears and the header aggregates (download count, size) are recomputed. * - * The event is invoked synchronously on the downloader's background thread, so this only - * uses thread-safe LiveData postValue. + * The event is invoked synchronously on the downloader's background thread; the reloads + * below hop onto [viewModelScope] and only post via thread-safe LiveData postValue. */ private fun onDownloadDeleted(id: Int) { - // The id space is shared: movie/series headers use their own id and individual - // episodes use the child id, so filtering both lists by id covers every card type. - // filter() returns null only while the list is still Loading; postHeaders/postChildren - // treat null as "no change", so there is nothing to update in that case. - postHeaders(_headerCards.success?.filter { it.data.id != id }) - postChildren(_childCards.success?.filter { it.data.id != id }) - // Keep multi-select state consistent: forget the removed id if it was selected. val currentSelection = selectedItemIds.value if (currentSelection?.contains(id) == true) { _selectedItemIds.postValue(currentSelection - id) updateSelectedBytes() } + + // Nothing has been shown yet (no list ever loaded), so there is nothing to refresh. + val context = appContext ?: return + updateHeaderList(context) + // Refresh the child screen too, without flashing a loading state. currentChildFolder is + // cleared once the child screen closes (clearChildren), so this is a no-op afterwards. + currentChildFolder?.let { folder -> + updateChildList(context, folder, pushLoading = false) + } } private fun updateStorageStats(visual: List) { @@ -616,6 +643,7 @@ class DownloadViewModel : ViewModel() { } fun clearChildren() { + currentChildFolder = null _childCards.postValue(Resource.Loading()) } From 60cfebd47506db5aecdf27133f4f4751ceabb714 Mon Sep 17 00:00:00 2001 From: LeC-D Date: Thu, 2 Jul 2026 21:07:32 -0400 Subject: [PATCH 4/5] =?UTF-8?q?refactor(download):=20apply=20review=20sugg?= =?UTF-8?q?estions=20=E2=80=94=20global=20app=20context,=20simpler=20selec?= =?UTF-8?q?tion=20and=20child=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use CloudStreamApp.context in onDownloadDeleted instead of retaining a context reference in the ViewModel, avoiding any leak risk. Drops the appContext/currentChildFolder fields. - Collapse the selection cleanup to updateSelectedItems { it?.minus(id) }. - Remove the deleted id from the child list directly via postChildren instead of re-fetching the folder from disk; the header list is still rebuilt from disk so series aggregates stay correct. - Revert the pushLoading parameter on updateChildList, no longer needed. Co-Authored-By: Claude Fable 5 --- .../ui/download/DownloadViewModel.kt | 53 ++++--------------- 1 file changed, 11 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 27ef76c7950..4377c3ffec6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.api.Log +import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.Resource @@ -68,14 +69,6 @@ class DownloadViewModel : ViewModel() { private val _selectedItemIds = ConsistentLiveData?>(null) val selectedItemIds: LiveData?> = _selectedItemIds - // Retained so onDownloadDeleted can rebuild the lists from disk after a - // delete triggered from anywhere in the app. We only ever store the - // application context (never an Activity) to avoid leaking it past the - // ViewModel's lifetime. currentChildFolder is the folder backing the child - // list currently on screen, or null when no child screen is open. - private var appContext: Context? = null - private var currentChildFolder: String? = null - init { // Keep the Downloads list in sync when a download is deleted/cancelled from // anywhere in the app (result page button, queue, notification, etc.). See @@ -214,7 +207,6 @@ class DownloadViewModel : ViewModel() { } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { - appContext = context.applicationContext // Do not push loading as it interrupts the UI //_headerCards.postValue(Resource.Loading()) @@ -377,16 +369,8 @@ class DownloadViewModel : ViewModel() { }) } - fun updateChildList( - context: Context, - folder: String, - pushLoading: Boolean = true - ) = viewModelScope.launchSafe { - appContext = context.applicationContext - currentChildFolder = folder - // Push loading when first opening the screen, but not on a live refresh - // (e.g. after a delete) where clearing the list would flicker the UI. - if (pushLoading) _childCards.postValue(Resource.Loading()) + fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + _childCards.postValue(Resource.Loading()) // always push loading val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> @@ -429,33 +413,19 @@ class DownloadViewModel : ViewModel() { * ViewModel level, so both [DownloadFragment] (headers) and [DownloadChildFragment] * (children) refresh. * - * We rebuild the lists from disk rather than filtering the in-memory lists by id. Filtering - * cannot handle a series correctly: deleting an *episode* fires the event with the child id, - * which must shrink (or remove) the parent *header* rather than match a header id, and the - * child list is frequently in the [Resource.Loading] state when the delete originates from - * outside the child screen. Reloading reads [VideoDownloadManager.getDownloadFileInfo], - * whose [KEY_DOWNLOAD_INFO] the delete has already cleared, so the gone episode/movie - * disappears and the header aggregates (download count, size) are recomputed. - * - * The event is invoked synchronously on the downloader's background thread; the reloads - * below hop onto [viewModelScope] and only post via thread-safe LiveData postValue. + * The header list is rebuilt from disk: deleting an *episode* fires the event with the + * child id, so the parent series header (download count, size) must be recomputed rather + * than filtered by id. The child list only needs the deleted id removed. The event is + * invoked on the downloader's background thread; everything below posts via thread-safe + * LiveData postValue. */ private fun onDownloadDeleted(id: Int) { // Keep multi-select state consistent: forget the removed id if it was selected. - val currentSelection = selectedItemIds.value - if (currentSelection?.contains(id) == true) { - _selectedItemIds.postValue(currentSelection - id) - updateSelectedBytes() - } + updateSelectedItems { it?.minus(id) } - // Nothing has been shown yet (no list ever loaded), so there is nothing to refresh. - val context = appContext ?: return + val context = CloudStreamApp.context ?: return updateHeaderList(context) - // Refresh the child screen too, without flashing a loading state. currentChildFolder is - // cleared once the child screen closes (clearChildren), so this is a no-op afterwards. - currentChildFolder?.let { folder -> - updateChildList(context, folder, pushLoading = false) - } + postChildren(_childCards.success?.filterNot { it.data.id == id }) } private fun updateStorageStats(visual: List) { @@ -643,7 +613,6 @@ class DownloadViewModel : ViewModel() { } fun clearChildren() { - currentChildFolder = null _childCards.postValue(Resource.Loading()) } From e75fbc2a75d10913c0ef8e068afadfbd02981903 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:18:11 +0000 Subject: [PATCH 5/5] remove unnecessary comment --- .../ui/download/DownloadViewModel.kt | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 4377c3ffec6..19c006b2847 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -404,20 +404,6 @@ class DownloadViewModel : ViewModel() { /** * Refreshes the Downloads screen in real time when a download is deleted/cancelled. - * - * Issue #1227: cancelling a download (or deleting it from the result page, the download - * queue, or a notification) fires [VideoDownloadManager.downloadDeleteEvent], but until - * now only the download *button* ([BaseFetchButton]) listened to it. The Downloads page - * itself never reacted, so the deleted episode/movie card stayed on screen until the user - * navigated away and back. We observe the same event here, at the activity-scoped (shared) - * ViewModel level, so both [DownloadFragment] (headers) and [DownloadChildFragment] - * (children) refresh. - * - * The header list is rebuilt from disk: deleting an *episode* fires the event with the - * child id, so the parent series header (download count, size) must be recomputed rather - * than filtered by id. The child list only needs the deleted id removed. The event is - * invoked on the downloader's background thread; everything below posts via thread-safe - * LiveData postValue. */ private fun onDownloadDeleted(id: Int) { // Keep multi-select state consistent: forget the removed id if it was selected. @@ -623,4 +609,4 @@ class DownloadViewModel : ViewModel() { val names: List, val parentName: String? ) -} \ No newline at end of file +}