diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 17ae16325..5e0373122 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -852,8 +852,7 @@ public final class VideoDetailFragment if (playQueue == null) { playQueue = new SinglePlayQueue(result); } - if (stack.isEmpty() || !stack.peek().getPlayQueue() - .equalStreams(playQueue)) { + if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) { stack.push(new StackItem(serviceId, url, title, playQueue)); } } @@ -1739,7 +1738,7 @@ public final class VideoDetailFragment // deleted/added items inside Channel/Playlist queue and makes possible to have // a history of played items @Nullable final StackItem stackPeek = stack.peek(); - if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { + if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) { @Nullable final PlayQueueItem playQueueItem = queue.getItem(); if (playQueueItem != null) { stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), @@ -1803,7 +1802,7 @@ public final class VideoDetailFragment // They are not equal when user watches something in popup while browsing in fragment and // then changes screen orientation. In that case the fragment will set itself as // a service listener and will receive initial call to onMetadataUpdate() - if (!queue.equalStreams(playQueue)) { + if (!queue.equals(playQueue)) { return; } @@ -2075,7 +2074,7 @@ public final class VideoDetailFragment final Iterator iterator = stack.descendingIterator(); while (iterator.hasNext()) { final StackItem next = iterator.next(); - if (next.getPlayQueue().equalStreams(queue)) { + if (next.getPlayQueue().equals(queue)) { item = next; break; } @@ -2089,8 +2088,7 @@ public final class VideoDetailFragment // Player will have STATE_IDLE when a user pressed back button if (isClearingQueueConfirmationRequired(activity) && playerIsNotStopped() - && activeQueue != null - && !activeQueue.equalStreams(playQueue)) { + && !Objects.equals(activeQueue, playQueue)) { showClearingQueueConfirmation(onAllow); } else { onAllow.run(); diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index d3e3ff1df..094032a06 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -397,7 +397,7 @@ public final class Player implements PlaybackListener, Listener { && newQueue.size() == 1 && newQueue.getItem() != null && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) - && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + && newQueue.getItem().getRecoveryPosition() != Long.MIN_VALUE) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() @@ -425,7 +425,7 @@ public final class Player implements PlaybackListener, Listener { && !samePlayQueue && !newQueue.isEmpty() && newQueue.getItem() != null - && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { + && newQueue.getItem().getRecoveryPosition() == Long.MIN_VALUE) { databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem()) .observeOn(AndroidSchedulers.mainThread()) // Do not place initPlayback() in doFinally() because @@ -1588,7 +1588,7 @@ public final class Player implements PlaybackListener, Listener { } // sync the player index with the queue index, and seek to the correct position - if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + if (item.getRecoveryPosition() != Long.MIN_VALUE) { simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition()); playQueue.unsetRecovery(playQueueIndex); } else { diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 88d7145bc..9092906fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; +import static org.schabi.newpipe.BuildConfig.DEBUG; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; -import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; public class MediaSourceManager { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java deleted file mode 100644 index cfa2ab316..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ /dev/null @@ -1,561 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.player.playqueue.events.AppendEvent; -import org.schabi.newpipe.player.playqueue.events.ErrorEvent; -import org.schabi.newpipe.player.playqueue.events.InitEvent; -import org.schabi.newpipe.player.playqueue.events.MoveEvent; -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.events.RecoveryEvent; -import org.schabi.newpipe.player.playqueue.events.RemoveEvent; -import org.schabi.newpipe.player.playqueue.events.ReorderEvent; -import org.schabi.newpipe.player.playqueue.events.SelectEvent; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.BackpressureStrategy; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; - -/** - * PlayQueue is responsible for keeping track of a list of streams and the index of - * the stream that should be currently playing. - *

- * This class contains basic manipulation of a playlist while also functions as a - * message bus, providing all listeners with new updates to the play queue. - *

- *

- * This class can be serialized for passing intents, but in order to start the - * message bus, it must be initialized. - *

- */ -public abstract class PlayQueue implements Serializable { - public static final boolean DEBUG = MainActivity.DEBUG; - @NonNull - private final AtomicInteger queueIndex; - private final List history = new ArrayList<>(); - - private List backup; - private List streams; - - private transient BehaviorSubject eventBroadcast; - private transient Flowable broadcastReceiver; - private transient boolean disposed = false; - - PlayQueue(final int index, final List startWith) { - streams = new ArrayList<>(startWith); - - if (streams.size() > index) { - history.add(streams.get(index)); - } - - queueIndex = new AtomicInteger(index); - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist actions - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Initializes the play queue message buses. - *

- * Also starts a self reporter for logging if debug mode is enabled. - *

- */ - public void init() { - eventBroadcast = BehaviorSubject.create(); - - broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) - .observeOn(AndroidSchedulers.mainThread()) - .startWithItem(new InitEvent()); - } - - /** - * Dispose the play queue by stopping all message buses. - */ - public void dispose() { - if (eventBroadcast != null) { - eventBroadcast.onComplete(); - } - - eventBroadcast = null; - broadcastReceiver = null; - disposed = true; - } - - /** - * Checks if the queue is complete. - *

- * A queue is complete if it has loaded all items in an external playlist - * single stream or local queues are always complete. - *

- * - * @return whether the queue is complete - */ - public abstract boolean isComplete(); - - /** - * Load partial queue in the background, does nothing if the queue is complete. - */ - public abstract void fetch(); - - /*////////////////////////////////////////////////////////////////////////// - // Readonly ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * @return the current index that should be played - */ - public int getIndex() { - return queueIndex.get(); - } - - /** - * Changes the current playing index to a new index. - *

- * This method is guarded using in a circular manner for index exceeding the play queue size. - *

- *

- * Will emit a {@link SelectEvent} if the index is not the current playing index. - *

- * - * @param index the index to be set - */ - public synchronized void setIndex(final int index) { - final int oldIndex = getIndex(); - - final int newIndex; - - if (index < 0) { - newIndex = 0; - } else if (index < streams.size()) { - // Regular assignment for index in bounds - newIndex = index; - } else if (streams.isEmpty()) { - // Out of bounds from here on - // Need to check if stream is empty to prevent arithmetic error and negative index - newIndex = 0; - } else if (isComplete()) { - // Circular indexing - newIndex = index % streams.size(); - } else { - // Index of last element - newIndex = streams.size() - 1; - } - - queueIndex.set(newIndex); - - if (oldIndex != newIndex) { - history.add(streams.get(newIndex)); - } - - /* - TODO: Documentation states that a SelectEvent will only be emitted if the new index is... - different from the old one but this is emitted regardless? Not sure what this what it does - exactly so I won't touch it - */ - broadcast(new SelectEvent(oldIndex, newIndex)); - } - - /** - * @return the current item that should be played, or null if the queue is empty - */ - @Nullable - public PlayQueueItem getItem() { - return getItem(getIndex()); - } - - /** - * @param index the index of the item to return - * @return the item at the given index, or null if the index is out of bounds - */ - @Nullable - public PlayQueueItem getItem(final int index) { - if (index < 0 || index >= streams.size()) { - return null; - } - return streams.get(index); - } - - /** - * Returns the index of the given item using referential equality. - * May be null despite play queue contains identical item. - * - * @param item the item to find the index of - * @return the index of the given item - */ - public int indexOf(@NonNull final PlayQueueItem item) { - return streams.indexOf(item); - } - - /** - * @return the current size of play queue. - */ - public int size() { - return streams.size(); - } - - /** - * Checks if the play queue is empty. - * - * @return whether the play queue is empty - */ - public boolean isEmpty() { - return streams.isEmpty(); - } - - /** - * Determines if the current play queue is shuffled. - * - * @return whether the play queue is shuffled - */ - public boolean isShuffled() { - return backup != null; - } - - /** - * @return an immutable view of the play queue - */ - @NonNull - public List getStreams() { - return Collections.unmodifiableList(streams); - } - - /*////////////////////////////////////////////////////////////////////////// - // Write ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Returns the play queue's update broadcast. - * May be null if the play queue message bus is not initialized. - * - * @return the play queue's update broadcast - */ - @Nullable - public Flowable getBroadcastReceiver() { - return broadcastReceiver; - } - - /** - * Changes the current playing index by an offset amount. - *

- * Will emit a {@link SelectEvent} if offset is non-zero. - *

- * - * @param offset the offset relative to the current index - */ - public synchronized void offsetIndex(final int offset) { - setIndex(getIndex() + offset); - } - - /** - * Notifies that a change has occurred. - */ - public synchronized void notifyChange() { - broadcast(new AppendEvent(0)); - } - - /** - * Appends the given {@link PlayQueueItem}s to the current play queue. - *

- * If the play queue is shuffled, then append the items to the backup queue as is and - * append the shuffle items to the play queue. - *

- *

- * Will emit a {@link AppendEvent} on any given context. - *

- * - * @param items {@link PlayQueueItem}s to append - */ - public synchronized void append(@NonNull final List items) { - final List itemList = new ArrayList<>(items); - - if (isShuffled()) { - backup.addAll(itemList); - Collections.shuffle(itemList); - } - if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued() - && !itemList.get(0).isAutoQueued()) { - streams.remove(streams.size() - 1); - } - streams.addAll(itemList); - - broadcast(new AppendEvent(itemList.size())); - } - - /** - * Removes the item at the given index from the play queue. - *

- * The current playing index will decrement if it is greater than the index being removed. - * On cases where the current playing index exceeds the playlist range, it is set to 0. - *

- *

- * Will emit a {@link RemoveEvent} if the index is within the play queue index range. - *

- * - * @param index the index of the item to remove - */ - public synchronized void remove(final int index) { - if (index >= streams.size() || index < 0) { - return; - } - removeInternal(index); - broadcast(new RemoveEvent(index, getIndex())); - } - - /** - * Report an exception for the item at the current index in order and skip to the next one - *

- * This is done as a separate event as the underlying manager may have - * different implementation regarding exceptions. - *

- */ - public synchronized void error() { - final int oldIndex = getIndex(); - queueIndex.incrementAndGet(); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - broadcast(new ErrorEvent(oldIndex, getIndex())); - } - - private synchronized void removeInternal(final int removeIndex) { - final int currentIndex = queueIndex.get(); - final int size = size(); - - if (currentIndex > removeIndex) { - queueIndex.decrementAndGet(); - - } else if (currentIndex >= size) { - queueIndex.set(currentIndex % (size - 1)); - - } else if (currentIndex == removeIndex && currentIndex == size - 1) { - queueIndex.set(0); - } - - if (backup != null) { - backup.remove(getItem(removeIndex)); - } - - history.remove(streams.remove(removeIndex)); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - } - - /** - * Moves a queue item at the source index to the target index. - *

- * If the item being moved is the currently playing, then the current playing index is set - * to that of the target. - * If the moved item is not the currently playing and moves to an index AFTER the - * current playing index, then the current playing index is decremented. - * Vice versa if the an item after the currently playing is moved BEFORE. - *

- * - * @param source the original index of the item - * @param target the new index of the item - */ - public synchronized void move(final int source, final int target) { - if (source < 0 || target < 0) { - return; - } - if (source >= streams.size() || target >= streams.size()) { - return; - } - - final int current = getIndex(); - if (source == current) { - queueIndex.set(target); - } else if (source < current && target >= current) { - queueIndex.decrementAndGet(); - } else if (source > current && target <= current) { - queueIndex.incrementAndGet(); - } - - final PlayQueueItem playQueueItem = streams.remove(source); - playQueueItem.setAutoQueued(false); - streams.add(target, playQueueItem); - broadcast(new MoveEvent(source, target)); - } - - /** - * Sets the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - * @param position the recovery position - */ - public synchronized void setRecovery(final int index, final long position) { - if (index < 0 || index >= streams.size()) { - return; - } - - streams.get(index).setRecoveryPosition(position); - broadcast(new RecoveryEvent(index, position)); - } - - /** - * Revoke the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - */ - public synchronized void unsetRecovery(final int index) { - setRecovery(index, PlayQueueItem.RECOVERY_UNSET); - } - - /** - * Shuffles the current play queue - *

- * This method first backs up the existing play queue and item being played. Then a newly - * shuffled play queue will be generated along with currently playing item placed at the - * beginning of the queue. This item will also be added to the history. - *

- *

- * Will emit a {@link ReorderEvent} if shuffled. - *

- * - * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on - * top, so shuffling a size-2 list does nothing) - */ - public synchronized void shuffle() { - // Create a backup if it doesn't already exist - // Note: The backup-list has to be created at all cost (even when size <= 2). - // Otherwise it's not possible to enter shuffle-mode! - if (backup == null) { - backup = new ArrayList<>(streams); - } - // Can't shuffle a list that's empty or only has one element - if (size() <= 2) { - return; - } - - final int originalIndex = getIndex(); - final PlayQueueItem currentItem = getItem(); - - Collections.shuffle(streams); - - // Move currentItem to the head of the queue - streams.remove(currentItem); - streams.add(0, currentItem); - queueIndex.set(0); - - history.add(currentItem); - - broadcast(new ReorderEvent(originalIndex, 0)); - } - - /** - * Unshuffles the current play queue if a backup play queue exists. - *

- * This method undoes shuffling and index will be set to the previously playing item if found, - * otherwise, the index will reset to 0. - *

- *

- * Will emit a {@link ReorderEvent} if a backup exists. - *

- */ - public synchronized void unshuffle() { - if (backup == null) { - return; - } - final int originIndex = getIndex(); - final PlayQueueItem current = getItem(); - - streams = backup; - backup = null; - - final int newIndex = streams.indexOf(current); - if (newIndex != -1) { - queueIndex.set(newIndex); - } else { - queueIndex.set(0); - } - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - - broadcast(new ReorderEvent(originIndex, queueIndex.get())); - } - - /** - * Selects previous played item. - * - * This method removes currently playing item from history and - * starts playing the last item from history if it exists - * - * @return true if history is not empty and the item can be played - * */ - public synchronized boolean previous() { - if (history.size() <= 1) { - return false; - } - - history.remove(history.size() - 1); - - final PlayQueueItem last = history.remove(history.size() - 1); - setIndex(indexOf(last)); - - return true; - } - - /* - * Compares two PlayQueues. Useful when a user switches players but queue is the same so - * we don't have to do anything with new queue. - * This method also gives a chance to track history of items in a queue in - * VideoDetailFragment without duplicating items from two identical queues - */ - public boolean equalStreams(@Nullable final PlayQueue other) { - if (other == null) { - return false; - } - if (size() != other.size()) { - return false; - } - for (int i = 0; i < size(); i++) { - final PlayQueueItem stream = streams.get(i); - final PlayQueueItem otherStream = other.streams.get(i); - // Check is based on serviceId and URL - if (stream.getServiceId() != otherStream.getServiceId() - || !stream.getUrl().equals(otherStream.getUrl())) { - return false; - } - } - return true; - } - - public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { - if (equalStreams(other)) { - //noinspection ConstantConditions - return other.getIndex() == getIndex(); //NOSONAR: other is not null - } - return false; - } - - public boolean isDisposed() { - return disposed; - } - /*////////////////////////////////////////////////////////////////////////// - // Rx Broadcast - //////////////////////////////////////////////////////////////////////////*/ - - private void broadcast(@NonNull final PlayQueueEvent event) { - if (eventBroadcast != null) { - eventBroadcast.onNext(event); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt new file mode 100644 index 000000000..1ae7e5cdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt @@ -0,0 +1,497 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.schabi.newpipe.player.playqueue.events.AppendEvent +import org.schabi.newpipe.player.playqueue.events.ErrorEvent +import org.schabi.newpipe.player.playqueue.events.InitEvent +import org.schabi.newpipe.player.playqueue.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.RecoveryEvent +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.ReorderEvent +import org.schabi.newpipe.player.playqueue.events.SelectEvent +import java.io.Serializable +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** + * PlayQueue is responsible for keeping track of a list of streams and the index of + * the stream that should be currently playing. + * + * This class contains basic manipulation of a playlist while also functions as a + * message bus, providing all listeners with new updates to the play queue. + * + * This class can be serialized for passing intents, but in order to start the + * message bus, it must be initialized. + */ +abstract class PlayQueue internal constructor( + index: Int, + startWith: List, +) : Serializable { + private val queueIndex = AtomicInteger(index) + private val history = mutableListOf() + private var backup = mutableListOf() + private var streams = startWith.toMutableList() + + @Transient + private var eventBroadcast: BehaviorSubject? = null + + /** + * Returns the play queue's update broadcast. + * May be null if the play queue message bus is not initialized. + * + * @return the play queue's update broadcast + */ + @Transient + var broadcastReceiver: Flowable? = null + private set + + @Transient + var isDisposed: Boolean = false + private set + + init { + if (streams.size > index) { + history.add(streams[index]) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist actions + ////////////////////////////////////////////////////////////////////////// */ + + /** + * Initializes the play queue message buses. + * + * Also starts a self reporter for logging if debug mode is enabled. + */ + fun init() { + eventBroadcast = BehaviorSubject.create() + + broadcastReceiver = + eventBroadcast!! + .toFlowable(BackpressureStrategy.BUFFER) + .observeOn(AndroidSchedulers.mainThread()) + .startWithItem(InitEvent()) + } + + /** + * Dispose the play queue by stopping all message buses. + */ + open fun dispose() { + eventBroadcast?.onComplete() + eventBroadcast = null + broadcastReceiver = null + this.isDisposed = true + } + + /** + * Checks if the queue is complete. + * + * A queue is complete if it has loaded all items in an external playlist + * single stream or local queues are always complete. + * + * @return whether the queue is complete + */ + abstract val isComplete: Boolean + + /** + * Load partial queue in the background, does nothing if the queue is complete. + */ + abstract fun fetch() + + /*////////////////////////////////////////////////////////////////////////// + // Readonly ops + ////////////////////////////////////////////////////////////////////////// */ + @set:Synchronized + var index: Int = 0 + /** + * @return the current index that should be played + */ + get() = queueIndex.get() + + /** + * Changes the current playing index to a new index. + * + * This method is guarded using in a circular manner for index exceeding the play queue size. + * + * Will emit a [SelectEvent] if the index is not the current playing index. + * + * @param index the index to be set + */ + set(index) { + val oldIndex = field + + val newIndex: Int + + if (index < 0) { + newIndex = 0 + } else if (index < streams.size) { + // Regular assignment for index in bounds + newIndex = index + } else if (streams.isEmpty()) { + // Out of bounds from here on + // Need to check if stream is empty to prevent arithmetic error and negative index + newIndex = 0 + } else if (this.isComplete) { + // Circular indexing + newIndex = index % streams.size + } else { + // Index of last element + newIndex = streams.size - 1 + } + + queueIndex.set(newIndex) + + if (oldIndex != newIndex) { + history.add(streams[newIndex]) + } + + /* + TODO: Documentation states that a SelectEvent will only be emitted if the new index is... + different from the old one but this is emitted regardless? Not sure what this what it does + exactly so I won't touch it + */ + broadcast(SelectEvent(oldIndex, newIndex)) + } + + /** + * @return the current item that should be played, or null if the queue is empty + */ + val item get() = getItem(this.index) + + /** + * @param index the index of the item to return + * @return the item at the given index, or null if the index is out of bounds + */ + fun getItem(index: Int) = streams.getOrNull(index) + + /** + * Returns the index of the given item using referential equality. + * May be null despite play queue contains identical item. + * + * @param item the item to find the index of + * @return the index of the given item + */ + fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item) + + /** + * @return the current size of play queue. + */ + fun size(): Int = streams.size + + /** + * Checks if the play queue is empty. + * + * @return whether the play queue is empty + */ + val isEmpty: Boolean + get() = streams.isEmpty() + + /** + * Determines if the current play queue is shuffled. + * + * @return whether the play queue is shuffled + */ + val isShuffled: Boolean + get() = backup.isNotEmpty() + + /** + * @return an immutable view of the play queue + */ + fun getStreams(): List = Collections.unmodifiableList(streams) + + /*////////////////////////////////////////////////////////////////////////// + // Write ops + ////////////////////////////////////////////////////////////////////////// */ + + /** + * Changes the current playing index by an offset amount. + * + * Will emit a [SelectEvent] if offset is non-zero. + * + * @param offset the offset relative to the current index + */ + @Synchronized + fun offsetIndex(offset: Int) { + this.index += offset + } + + /** + * Notifies that a change has occurred. + */ + @Synchronized + fun notifyChange() { + broadcast(AppendEvent(0)) + } + + /** + * Appends the given [PlayQueueItem]s to the current play queue. + * + * If the play queue is shuffled, then append the items to the backup queue as is and + * append the shuffle items to the play queue. + * + * Will emit a [AppendEvent] on any given context. + * + * @param items [PlayQueueItem]s to append + */ + @Synchronized + fun append(items: List) { + val itemList = items.toMutableList() + + if (this.isShuffled) { + backup.addAll(itemList) + itemList.shuffle() + } + if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) { + streams.removeAt(streams.lastIndex) + } + streams.addAll(itemList) + + broadcast(AppendEvent(itemList.size)) + } + + /** + * Removes the item at the given index from the play queue. + * + * The current playing index will decrement if it is greater than the index being removed. + * On cases where the current playing index exceeds the playlist range, it is set to 0. + * + * Will emit a [RemoveEvent] if the index is within the play queue index range. + * + * @param index the index of the item to remove + */ + @Synchronized + fun remove(index: Int) { + if (index >= streams.size || index < 0) { + return + } + removeInternal(index) + broadcast(RemoveEvent(index, this.index)) + } + + /** + * Report an exception for the item at the current index in order and skip to the next one + * + * This is done as a separate event as the underlying manager may have + * different implementation regarding exceptions. + */ + @Synchronized + fun error() { + val oldIndex = this.index + queueIndex.incrementAndGet() + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + broadcast(ErrorEvent(oldIndex, this.index)) + } + + @Synchronized + private fun removeInternal(removeIndex: Int) { + val currentIndex = queueIndex.get() + val size = size() + + if (currentIndex > removeIndex) { + queueIndex.decrementAndGet() + } else if (currentIndex >= size) { + queueIndex.set(currentIndex % (size - 1)) + } else if (currentIndex == removeIndex && currentIndex == size - 1) { + queueIndex.set(0) + } + + backup.remove(getItem(removeIndex)!!) + + history.remove(streams.removeAt(removeIndex)) + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + } + + /** + * Moves a queue item at the source index to the target index. + * + * If the item being moved is the currently playing, then the current playing index is set + * to that of the target. + * If the moved item is not the currently playing and moves to an index **AFTER** the + * current playing index, then the current playing index is decremented. + * Vice versa if the an item after the currently playing is moved **BEFORE**. + * + * @param source the original index of the item + * @param target the new index of the item + */ + @Synchronized + fun move( + source: Int, + target: Int, + ) { + if (source < 0 || target < 0) { + return + } + if (source >= streams.size || target >= streams.size) { + return + } + + val current = this.index + if (source == current) { + queueIndex.set(target) + } else if (source < current && target >= current) { + queueIndex.decrementAndGet() + } else if (source > current && target <= current) { + queueIndex.incrementAndGet() + } + + val playQueueItem = streams.removeAt(source) + playQueueItem.isAutoQueued = false + streams.add(target, playQueueItem) + broadcast(MoveEvent(source, target)) + } + + /** + * Sets the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * + * @param index index of the item + * @param position the recovery position + */ + @Synchronized + fun setRecovery( + index: Int, + position: Long, + ) { + streams.getOrNull(index)?.let { + it.recoveryPosition = position + broadcast(RecoveryEvent(index, position)) + } + } + + /** + * Revoke the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * + * @param index index of the item + */ + @Synchronized + fun unsetRecovery(index: Int) { + setRecovery(index, Long.Companion.MIN_VALUE) + } + + /** + * Shuffles the current play queue + * + * This method first backs up the existing play queue and item being played. Then a newly + * shuffled play queue will be generated along with currently playing item placed at the + * beginning of the queue. This item will also be added to the history. + * + * Will emit a [ReorderEvent] if shuffled. + * + * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on + * top, so shuffling a size-2 list does nothing) + */ + @Synchronized + fun shuffle() { + // Create a backup if it doesn't already exist + // Note: The backup-list has to be created at all cost (even when size <= 2). + // Otherwise it's not possible to enter shuffle-mode! + if (backup.isEmpty()) { + backup = streams.toMutableList() + } + // Can't shuffle a list that's empty or only has one element + if (size() <= 2) { + return + } + + val originalIndex = this.index + val currentItem = this.item + + streams.shuffle() + + // Move currentItem to the head of the queue + streams.remove(currentItem!!) + streams.add(0, currentItem) + queueIndex.set(0) + + history.add(currentItem) + + broadcast(ReorderEvent(originalIndex, 0)) + } + + /** + * Unshuffles the current play queue if a backup play queue exists. + * + * This method undoes shuffling and index will be set to the previously playing item if found, + * otherwise, the index will reset to 0. + * + * Will emit a [ReorderEvent] if a backup exists. + */ + @Synchronized + fun unshuffle() { + if (backup.isEmpty()) { + return + } + val originIndex = this.index + val current = this.item + + streams = backup + backup = mutableListOf() + + val newIndex = streams.indexOf(current!!) + if (newIndex != -1) { + queueIndex.set(newIndex) + } else { + queueIndex.set(0) + } + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + + broadcast(ReorderEvent(originIndex, queueIndex.get())) + } + + /** + * Selects previous played item. + * + * This method removes currently playing item from history and + * starts playing the last item from history if it exists + * + * @return true if history is not empty and the item can be played + */ + @Synchronized + fun previous(): Boolean { + if (history.size <= 1) { + return false + } + + history.removeAt(history.size - 1) + + val last = history.removeAt(history.size - 1) + this.index = indexOf(last) + + return true + } + + /* + * Compares two PlayQueues. Useful when a user switches players but queue is the same so + * we don't have to do anything with new queue. + * This method also gives a chance to track history of items in a queue in + * VideoDetailFragment without duplicating items from two identical queues + */ + override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams + + override fun hashCode(): Int = streams.hashCode() + + fun equalStreamsAndIndex(other: PlayQueue?): Boolean { + return equals(other) && other!!.index == this.index // NOSONAR: other is not null + } + + /*////////////////////////////////////////////////////////////////////////// + // Rx Broadcast + ////////////////////////////////////////////////////////////////////////// */ + private fun broadcast(event: PlayQueueEvent) { + eventBroadcast?.onNext(event) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java deleted file mode 100644 index 759c51267..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.Serializable; -import java.util.List; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class PlayQueueItem implements Serializable { - public static final long RECOVERY_UNSET = Long.MIN_VALUE; - private static final String EMPTY_STRING = ""; - - @NonNull - private final String title; - @NonNull - private final String url; - private final int serviceId; - private final long duration; - @NonNull - private final List thumbnails; - @NonNull - private final String uploader; - private final String uploaderUrl; - @NonNull - private final StreamType streamType; - - private boolean isAutoQueued; - - private long recoveryPosition; - private Throwable error; - - PlayQueueItem(@NonNull final StreamInfo info) { - this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), - info.getThumbnails(), info.getUploaderName(), - info.getUploaderUrl(), info.getStreamType()); - - if (info.getStartPosition() > 0) { - setRecoveryPosition(info.getStartPosition() * 1000); - } - } - - PlayQueueItem(@NonNull final StreamInfoItem item) { - this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), - item.getThumbnails(), item.getUploaderName(), - item.getUploaderUrl(), item.getStreamType()); - } - - @SuppressWarnings("ParameterNumber") - private PlayQueueItem(@Nullable final String name, @Nullable final String url, - final int serviceId, final long duration, - final List thumbnails, @Nullable final String uploader, - final String uploaderUrl, @NonNull final StreamType streamType) { - this.title = name != null ? name : EMPTY_STRING; - this.url = url != null ? url : EMPTY_STRING; - this.serviceId = serviceId; - this.duration = duration; - this.thumbnails = thumbnails; - this.uploader = uploader != null ? uploader : EMPTY_STRING; - this.uploaderUrl = uploaderUrl; - this.streamType = streamType; - - this.recoveryPosition = RECOVERY_UNSET; - } - - @NonNull - public String getTitle() { - return title; - } - - @NonNull - public String getUrl() { - return url; - } - - public int getServiceId() { - return serviceId; - } - - public long getDuration() { - return duration; - } - - @NonNull - public List getThumbnails() { - return thumbnails; - } - - @NonNull - public String getUploader() { - return uploader; - } - - public String getUploaderUrl() { - return uploaderUrl; - } - - @NonNull - public StreamType getStreamType() { - return streamType; - } - - public long getRecoveryPosition() { - return recoveryPosition; - } - - /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { - this.recoveryPosition = recoveryPosition; - } - - @Nullable - public Throwable getError() { - return error; - } - - @NonNull - public Single getStream() { - return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) - .subscribeOn(Schedulers.io()) - .doOnError(throwable -> error = throwable); - } - - public boolean isAutoQueued() { - return isAutoQueued; - } - - //////////////////////////////////////////////////////////////////////////// - // Item States, keep external access out - //////////////////////////////////////////////////////////////////////////// - - public void setAutoQueued(final boolean autoQueued) { - isAutoQueued = autoQueued; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt new file mode 100644 index 000000000..d6b4b0402 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.util.ExtractorHelper +import java.io.Serializable +import java.util.Objects + +class PlayQueueItem private constructor( + val title: String, + val url: String, + val serviceId: Int, + val duration: Long, + val thumbnails: List, + val uploader: String, + val uploaderUrl: String?, + val streamType: StreamType, +) : Serializable { + // + // ////////////////////////////////////////////////////////////////////// */ + // Item States, keep external access out + // + // ////////////////////////////////////////////////////////////////////// */ + var isAutoQueued: Boolean = false + + // package-private + var recoveryPosition = Long.Companion.MIN_VALUE + var error: Throwable? = null + private set + + constructor(info: StreamInfo) : this( + info.name.orEmpty(), + info.url.orEmpty(), + info.serviceId, + info.duration, + info.thumbnails, + info.uploaderName.orEmpty(), + info.uploaderUrl, + info.streamType, + ) { + if (info.startPosition > 0) { + this.recoveryPosition = info.startPosition * 1000 + } + } + + constructor(item: StreamInfoItem) : this( + item.name.orEmpty(), + item.url.orEmpty(), + item.serviceId, + item.duration, + item.thumbnails, + item.uploaderName.orEmpty(), + item.uploaderUrl, + item.streamType, + ) + + val stream: Single + get() = + ExtractorHelper + .getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .doOnError { throwable -> error = throwable } + + override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url + + override fun hashCode() = Objects.hash(url, serviceId) +} diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java index d10d33f7e..9addcfc1e 100644 --- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java +++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java @@ -3,17 +3,15 @@ package org.schabi.newpipe.player.playqueue; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; public class PlayQueueItemTest { public static final String URL = "MY_URL"; @Test - public void equalsMustNotBeOverloaded() { + public void equalsMustWork() { final PlayQueueItem a = PlayQueueTest.makeItemWithUrl(URL); final PlayQueueItem b = PlayQueueTest.makeItemWithUrl(URL); - assertEquals(a, a); - assertNotEquals(a, b); // they should compare different even if they have the same data + assertEquals(a, b); } } diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java index 022089f37..24212b786 100644 --- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java +++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java @@ -3,6 +3,8 @@ package org.schabi.newpipe.player.playqueue; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; @@ -13,12 +15,14 @@ import java.util.Objects; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; +@RunWith(Enclosed.class) @SuppressWarnings("checkstyle:HideUtilityClassConstructor") public class PlayQueueTest { static PlayQueue makePlayQueue(final int index, final List streams) { @@ -168,7 +172,7 @@ public class PlayQueueTest { final List streams = Collections.nCopies(5, item1); final PlayQueue queue1 = makePlayQueue(0, streams); final PlayQueue queue2 = makePlayQueue(0, streams); - assertTrue(queue1.equalStreams(queue2)); + assertEquals(queue1, queue2); assertTrue(queue1.equalStreamsAndIndex(queue2)); } @@ -177,7 +181,7 @@ public class PlayQueueTest { final List streams = Collections.nCopies(5, item1); final PlayQueue queue1 = makePlayQueue(1, streams); final PlayQueue queue2 = makePlayQueue(4, streams); - assertTrue(queue1.equalStreams(queue2)); + assertEquals(queue1, queue2); assertFalse(queue1.equalStreamsAndIndex(queue2)); } @@ -187,7 +191,7 @@ public class PlayQueueTest { final List streams2 = Collections.nCopies(5, item2); final PlayQueue queue1 = makePlayQueue(0, streams1); final PlayQueue queue2 = makePlayQueue(0, streams2); - assertFalse(queue1.equalStreams(queue2)); + assertNotEquals(queue1, queue2); } @Test @@ -196,7 +200,7 @@ public class PlayQueueTest { final List streams2 = Collections.nCopies(6, item2); final PlayQueue queue1 = makePlayQueue(0, streams1); final PlayQueue queue2 = makePlayQueue(0, streams2); - assertFalse(queue1.equalStreams(queue2)); + assertNotEquals(queue1, queue2); } } }