diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index cb38d9bae1c..5047d4b7d99 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -17,7 +17,6 @@ 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 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.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.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.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, Void> implements PlaylistControlViewHolder, DebounceSavable { @@ -97,6 +93,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment { + 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 boolean onOptionsItemSelected(final MenuItem item) { } 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 @@ private void sharePlaylist(final PlayListShareMode shareMode) { } 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 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 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 @@ private void openRemoveDuplicatesDialog() { } 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) { diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt new file mode 100644 index 00000000000..954449bb491 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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.Idle) + val workState: LiveData = _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() + 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) { + 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. + } +}