Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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<List<PlaylistStreamEntry>, Void>
implements PlaylistControlViewHolder, DebounceSavable {
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -130,6 +125,19 @@ public void onCreate(final Bundle savedInstanceState) {
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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading