Fix playlist not clearing when navigating back (#8888)

This commit is contained in:
Elitenotavailable authored and Tobi committed 2026-08-20 22:53:24 +02:00
1 parent 1d1f801f54
commit 2acfbab5f4
2 files changed
+167 -110

No files matched your search

@@ -17,7 +17,6 @@ import android.os.Parcelable;
import android.text.InputType;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -31,6 +30,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@@ -40,7 +40,6 @@ import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import org.schabi.newpipe.database.stream.model.StreamEntity;
@@ -55,7 +54,6 @@ import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.DeviceUtils;
@@ -72,13 +70,11 @@ import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
implements PlaylistControlViewHolder, DebounceSavable {
@@ -97,6 +93,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private ItemTouchHelper itemTouchHelper;
private LocalPlaylistManager playlistManager;
private LocalPlaylistViewModel viewModel;
private Subscription databaseSubscription;
private CompositeDisposable disposables;
@@ -105,8 +102,6 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private AtomicBoolean isLoadingComplete;
/** Used to debounce saving playlist edits to disk. */
private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
/**
* The pager adapter that the fragment is created from when it is used as frontpage, i.e.
@@ -130,6 +125,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
super.onCreate(savedInstanceState);
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
viewModel = new ViewModelProvider(this).get(LocalPlaylistViewModel.class);
viewModel.getWorkState().observe(this, state -> {
if (state instanceof LocalPlaylistViewModel.WorkState.Loading) {
showLoading();
} else {
hideLoading();
if (state instanceof LocalPlaylistViewModel.WorkState.Error error) {
showError(new ErrorInfo(error.getThrowable(), UserAction.REQUESTED_BOOKMARK,
"Playlist operation"));
}
}
});
disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
@@ -369,13 +377,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
if (!isRewritingPlaylist) {
openRemoveWatchedConfirmationDialog();
}
openRemoveWatchedConfirmationDialog();
} else if (item.getItemId() == R.id.menu_item_remove_duplicates) {
if (!isRewritingPlaylist) {
openRemoveDuplicatesDialog();
}
openRemoveDuplicatesDialog();
} else {
return super.onOptionsItemSelected(item);
}
@@ -421,80 +425,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (isRewritingPlaylist) {
return;
}
isRewritingPlaylist = true;
showLoading();
final var recordManager = new HistoryRecordManager(getContext());
final var historyIdsMaybe = recordManager.getStreamHistorySortedById()
.firstElement()
// already sorted by ^ getStreamHistorySortedById(), binary search can be used
.map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId)
.collect(Collectors.toList()));
final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId)
.firstElement()
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
// Remove Watched, Functionality data
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
final boolean isThumbnailPermanent = playlistManager
.getIsPlaylistThumbnailPermanent(playlistId);
boolean thumbnailVideoRemoved = false;
final var streamStates = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet();
for (int i = 0; i < playlist.size(); i++) {
final var playlistItem = playlist.get(i);
final var streamStateEntity = streamStates.get(i);
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final long duration = playlistItem.toStreamInfoItem().getDuration();
if (indexInHistory < 0 // stream is not in history
// stream is in history but the streamStateEntity is null
// if the stream was played for less than 5 seconds, see
// StreamStateEntity#PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|| streamStateEntity == null
|| (!removePartiallyWatched
&& !streamStateEntity.isFinished(duration))) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
});
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(flow -> {
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
final boolean thumbnailVideoRemoved = flow.second;
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
debounceSaver.setHasChangesToSave();
saveImmediate();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
}
final long videoCount = itemListAdapter.getItemsList().size();
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
if (videoCount == 0) {
showEmptyState();
}
hideLoading();
isRewritingPlaylist = false;
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
"Removing watched videos, partially watched=" + removePartiallyWatched))));
viewModel.removeWatchedStreams(playlistId, removePartiallyWatched);
}
@Override
@@ -633,29 +564,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
private void removeDuplicatesInPlaylist() {
if (isRewritingPlaylist) {
return;
}
isRewritingPlaylist = true;
showLoading();
final var streamsMaybe = playlistManager
.getDistinctPlaylistStreams(playlistId).firstElement();
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(itemsToKeep -> {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
saveImmediate();
hideLoading();
isRewritingPlaylist = false;
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
"Removing duplicated streams"))));
viewModel.removeDuplicates(playlistId);
}
private void deleteItem(final PlaylistStreamEntry item) {
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import android.app.Application
import android.util.Pair
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.Collections
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.local.history.HistoryRecordManager
class LocalPlaylistViewModel(application: Application) : AndroidViewModel(application) {
private val playlistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(application))
private val recordManager = HistoryRecordManager(application)
private val disposables = CompositeDisposable()
private val _workState = MutableLiveData<WorkState>(WorkState.Idle)
val workState: LiveData<WorkState> = _workState
sealed class WorkState {
object Idle : WorkState()
object Loading : WorkState()
data class Error(val throwable: Throwable) : WorkState()
object Success : WorkState()
}
/**
* Removes watched streams from the playlist.
* This operation is performed in the background and survives fragment destruction
* as it is not disposed when the ViewModel is cleared (to fix #8888).
*/
fun removeWatchedStreams(playlistId: Long, removePartiallyWatched: Boolean) {
_workState.value = WorkState.Loading
val historyIdsMaybe = recordManager.streamHistorySortedById
.firstElement()
.map { historyList ->
historyList.map { it.streamId }
}
val streamsMaybe = playlistManager.getPlaylistStreams(playlistId)
.firstElement()
.zipWith(historyIdsMaybe) { playlist, historyStreamIds ->
val itemsToKeep = mutableListOf<PlaylistStreamEntry>()
val isThumbnailPermanent = playlistManager.getIsPlaylistThumbnailPermanent(playlistId)
var thumbnailVideoRemoved = false
val streamStates = recordManager.loadLocalStreamStateBatch(playlist).blockingGet()
for (i in playlist.indices) {
val playlistItem = playlist[i]
val streamStateEntity = streamStates[i]
val indexInHistory = Collections.binarySearch(historyStreamIds, playlistItem.streamId)
val duration = playlistItem.toStreamInfoItem().duration
if (indexInHistory < 0 || streamStateEntity == null ||
(!removePartiallyWatched && !streamStateEntity.isFinished(duration))
) {
itemsToKeep.add(playlistItem)
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved &&
playlistManager.getPlaylistThumbnailStreamId(playlistId) ==
playlistItem.streamEntity.uid
) {
thumbnailVideoRemoved = true
}
}
Pair(itemsToKeep, thumbnailVideoRemoved)
}
val disposable = streamsMaybe
.subscribeOn(Schedulers.io())
.flatMapCompletable { flow ->
val itemsToKeep = flow.first
val thumbnailVideoRemoved = flow.second
val streamIds = itemsToKeep.map { it.streamId }
playlistManager.updateJoin(playlistId, streamIds)
.andThen(
Completable.fromAction {
if (thumbnailVideoRemoved) {
updateThumbnailUrl(playlistId, itemsToKeep)
}
}
)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ _workState.value = WorkState.Success },
{ _workState.value = WorkState.Error(it) }
)
disposables.add(disposable)
}
/**
* Removes duplicate streams from the playlist.
*/
fun removeDuplicates(playlistId: Long) {
_workState.value = WorkState.Loading
val disposable = playlistManager.getDistinctPlaylistStreams(playlistId)
.firstElement()
.subscribeOn(Schedulers.io())
.flatMapCompletable { itemsToKeep ->
val streamIds = itemsToKeep.map { it.streamId }
playlistManager.updateJoin(playlistId, streamIds)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ _workState.value = WorkState.Success },
{ _workState.value = WorkState.Error(it) }
)
disposables.add(disposable)
}
private fun updateThumbnailUrl(playlistId: Long, itemsToKeep: List<PlaylistStreamEntry>) {
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
return
}
val thumbnailStreamId = if (itemsToKeep.isNotEmpty()) {
itemsToKeep[0].streamEntity.uid
} else {
PlaylistEntity.DEFAULT_THUMBNAIL_ID
}
playlistManager.changePlaylistThumbnail(playlistId, thumbnailStreamId, false).blockingGet()
}
override fun onCleared() {
super.onCleared()
// We deliberately do not clear disposables here to allow pending
// database mutations to finish even if the user leaves the screen.
}
}