diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 3a4b07cd8..43c956783 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -88,6 +88,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager import org.schabi.newpipe.local.playlist.LocalPlaylistFragment import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerIntentType import org.schabi.newpipe.player.PlayerService import org.schabi.newpipe.player.PlayerType import org.schabi.newpipe.player.event.OnKeyDownListener @@ -1044,8 +1045,10 @@ class VideoDetailFragment : tryAddVideoPlayerView() val playerIntent = NavigationHelper.getPlayerIntent( - requireContext(), PlayerService::class.java, queue, true, autoPlayEnabled + requireContext(), PlayerService::class.java, queue, PlayerIntentType.AllOthers ) + .putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled) + .putExtra(Player.RESUME_PLAYBACK, true) ContextCompat.startForegroundService(activity, playerIntent) } 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 4d6647d12..eef870f82 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -24,12 +24,12 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; import static com.google.android.exoplayer2.Player.DiscontinuityReason; import static com.google.android.exoplayer2.Player.Listener; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static com.google.android.exoplayer2.Player.RepeatMode; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode; import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs; @@ -61,6 +61,7 @@ import android.view.LayoutInflater; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; @@ -107,6 +108,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; @@ -116,6 +118,7 @@ import org.schabi.newpipe.player.ui.PlayerUiList; import org.schabi.newpipe.player.ui.PopupPlayerUi; import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.DependentPreferenceHelper; +import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SerializedCache; @@ -123,15 +126,18 @@ import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.IntStream; import coil3.target.Target; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.SerialDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; /** * The ExoPlayer wrapper & Player business logic. @@ -157,15 +163,13 @@ public final class Player implements PlaybackListener, Listener { // Intent //////////////////////////////////////////////////////////////////////////*/ - public static final String REPEAT_MODE = "repeat_mode"; public static final String PLAYBACK_QUALITY = "playback_quality"; public static final String PLAY_QUEUE_KEY = "play_queue_key"; - public static final String ENQUEUE = "enqueue"; - public static final String ENQUEUE_NEXT = "enqueue_next"; public static final String RESUME_PLAYBACK = "resume_playback"; public static final String PLAY_WHEN_READY = "play_when_ready"; public static final String PLAYER_TYPE = "player_type"; - public static final String IS_MUTED = "is_muted"; + public static final String PLAYER_INTENT_TYPE = "player_intent_type"; + public static final String PLAYER_INTENT_DATA = "player_intent_data"; /*////////////////////////////////////////////////////////////////////////// // Time constants @@ -251,6 +255,8 @@ public final class Player implements PlaybackListener, Listener { private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); @NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); + @NonNull + private final CompositeDisposable streamItemDisposable = new CompositeDisposable(); /*////////////////////////////////////////////////////////////////////////// // Utils @@ -343,49 +349,120 @@ public final class Player implements PlaybackListener, Listener { @SuppressWarnings("MethodLength") public void handleIntent(@NonNull final Intent intent) { - // fail fast if no play queue was provided - final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); - if (queueCache == null) { + final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE, + PlayerIntentType.class); + if (playerIntentType == null) { return; } - final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class); - if (newQueue == null) { - return; + // TODO: this should be in the second switch below, but I’m not sure whether I + // can move the initUIs stuff without breaking the setup for edge cases somehow. + // when playing from a timestamp, keep the current player as-is. + if (playerIntentType != PlayerIntentType.TimestampChange) { + playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class); } - - final PlayerType oldPlayerType = playerType; - playerType = PlayerType.retrieveFromIntent(intent); initUIsForCurrentPlayerType(); - // We need to setup audioOnly before super(), see "sourceOf" isAudioOnly = audioPlayerSelected(); if (intent.hasExtra(PLAYBACK_QUALITY)) { videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); } - // Resolve enqueue intents - if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) { - playQueue.append(newQueue.getStreams()); - return; + final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); - // Resolve enqueue next intents - } else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) { - final int currentIndex = playQueue.getIndex(); - playQueue.append(newQueue.getStreams()); - playQueue.move(playQueue.size() - 1, currentIndex + 1); + switch (playerIntentType) { + case Enqueue -> { + if (playQueue != null) { + final PlayQueue newQueue = getPlayQueueFromCache(intent); + if (newQueue == null) { + return; + } + playQueue.append(newQueue.getStreams()); + return; + } + + // TODO: This falls through to the old logic, there was no playQueue + // yet so we should start the player and add the new video + break; + } + case EnqueueNext -> { + if (playQueue != null) { + final PlayQueue newQueue = getPlayQueueFromCache(intent); + if (newQueue == null) { + return; + } + final PlayQueueItem newItem = newQueue.getStreams().get(0); + newQueue.enqueueNext(newItem, false); + return; + } + + // TODO: This falls through to the old logic, there was no playQueue + // yet so we should start the player and add the new video + break; + } + case TimestampChange -> { + final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent, + PLAYER_INTENT_DATA, TimestampChangeData.class)); + final Single single = + ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false); + streamItemDisposable.add(single.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + final @Nullable PlayQueue oldPlayQueue = playQueue; + info.setStartPosition(data.getSeconds()); + final PlayQueueItem item = new PlayQueueItem(info); + + // If the stream is already playing, + // we can just seek to the appropriate timestamp + if (oldPlayQueue != null && item.equals(oldPlayQueue.getItem())) { + // Player can have state = IDLE when playback is stopped or failed + // and we should retry in this case + if (simpleExoPlayer.getPlaybackState() + == com.google.android.exoplayer2.Player.STATE_IDLE) { + simpleExoPlayer.prepare(); + } + simpleExoPlayer.seekTo(oldPlayQueue.getIndex(), + data.getSeconds() * 1000L); + simpleExoPlayer.setPlayWhenReady(playWhenReady); + + } else { + final PlayQueue newPlayQueue; + + // If there is no queue yet, just add our item + if (oldPlayQueue == null) { + newPlayQueue = new SinglePlayQueue(item); + + // else we add the timestamped stream behind the current video + // and start playing it. + } else { + oldPlayQueue.enqueueNext(item, true); + oldPlayQueue.offsetIndex(1); + newPlayQueue = oldPlayQueue; + } + initPlayback(newPlayQueue, playWhenReady); + } + + }, throwable -> { + // This will only show a snackbar if the passed context has a root view: + // otherwise it will resort to showing a notification, so we are safe + // here. + final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, + data.getUrl(), null, data.getUrl()); + ErrorUtil.createNotification(context, info); + })); + return; + } + case AllOthers -> { + // fallthrough; TODO: put other intent data in separate cases + } + } + + final PlayQueue newQueue = getPlayQueueFromCache(intent); + if (newQueue == null) { return; } - final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); - final float playbackSpeed = savedParameters.speed; - final float playbackPitch = savedParameters.pitch; - final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( - R.string.playback_skip_silence_key), getPlaybackSkipSilence()); - + // branching parameters for below final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); - final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); - final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); - final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted()); /* * TODO As seen in #7427 this does not work: @@ -400,7 +477,7 @@ public final class Player implements PlaybackListener, Listener { if (!exoPlayerIsNull() && newQueue.size() == 1 && newQueue.getItem() != null && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null - && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) + && newQueue.getItem().equals(playQueue.getItem()) && newQueue.getItem().getRecoveryPosition() != Long.MIN_VALUE) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case @@ -426,7 +503,8 @@ public final class Player implements PlaybackListener, Listener { } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) && DependentPreferenceHelper.getResumePlaybackEnabled(context) - && !samePlayQueue + // !samePlayQueue + && (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue)) && !newQueue.isEmpty() && newQueue.getItem() != null && newQueue.getItem().getRecoveryPosition() == Long.MIN_VALUE) { @@ -442,30 +520,30 @@ public final class Player implements PlaybackListener, Listener { newQueue.setRecovery(newQueue.getIndex(), state.getProgressMillis()); } - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); + initPlayback(newQueue, playWhenReady); }, error -> { if (DEBUG) { Log.w(TAG, "Failed to start playback", error); } // In case any error we can start playback without history - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); + initPlayback(newQueue, playWhenReady); }, () -> { // Completed but not found in history - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); + initPlayback(newQueue, playWhenReady); } )); } else { // Good to go... // In a case of equal PlayQueues we can re-init old one but only when it is disposed - initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed, - playbackPitch, playbackSkipSilence, playWhenReady, isMuted); + initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady); } + } + + + public void handleIntentPost(final PlayerType oldPlayerType) { if (oldPlayerType != playerType && playQueue != null) { // If playerType changes from one to another we should reload the player // (to disable/enable video stream or to set quality) @@ -476,6 +554,19 @@ public final class Player implements PlaybackListener, Listener { NavigationHelper.sendPlayerStartedEvent(context); } + @Nullable + private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) { + final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); + if (queueCache == null) { + return null; + } + final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class); + if (newQueue == null) { + return null; + } + return newQueue; + } + private void initUIsForCurrentPlayerType() { if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN) || (UIs.get(PopupPlayerUi.class) != null @@ -511,16 +602,13 @@ public final class Player implements PlaybackListener, Listener { } private void initPlayback(@NonNull final PlayQueue queue, - @RepeatMode final int repeatMode, - final float playbackSpeed, - final float playbackPitch, - final boolean playbackSkipSilence, - final boolean playOnReady, - final boolean isMuted) { + final boolean playOnReady) { destroyPlayer(); initPlayer(playOnReady); - setRepeatMode(repeatMode); - setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence); + final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( + R.string.playback_skip_silence_key), getPlaybackSkipSilence()); + final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); + setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence); playQueue = queue; playQueue.init(); @@ -528,7 +616,7 @@ public final class Player implements PlaybackListener, Listener { UIs.call(PlayerUi::initPlayback); - simpleExoPlayer.setVolume(isMuted ? 0 : 1); + simpleExoPlayer.setVolume(isMuted() ? 0 : 1); notifyQueueUpdateToListeners(); } @@ -616,6 +704,7 @@ public final class Player implements PlaybackListener, Listener { databaseUpdateDisposable.clear(); progressUpdateDisposable.set(null); + streamItemDisposable.clear(); UIs.destroyAllOfType(null); } @@ -1171,16 +1260,25 @@ public final class Player implements PlaybackListener, Listener { return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); } - public void setRepeatMode(@RepeatMode final int repeatMode) { + public void cycleNextRepeatMode() { if (!exoPlayerIsNull()) { + @RepeatMode final int repeatMode; + switch (simpleExoPlayer.getRepeatMode()) { + case REPEAT_MODE_OFF: + repeatMode = REPEAT_MODE_ONE; + break; + case REPEAT_MODE_ONE: + repeatMode = REPEAT_MODE_ALL; + break; + case REPEAT_MODE_ALL: + default: + repeatMode = REPEAT_MODE_OFF; + break; + } simpleExoPlayer.setRepeatMode(repeatMode); } } - public void cycleNextRepeatMode() { - setRepeatMode(nextRepeatMode(getRepeatMode())); - } - @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { if (DEBUG) { diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt new file mode 100644 index 000000000..ed0c19c99 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt @@ -0,0 +1,24 @@ +package org.schabi.newpipe.player + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +// We model this as an enum class plus one struct for each enum value +// so we can consume it from Java properly. After converting to Kotlin, +// we could switch to a sealed enum class & a proper Kotlin `when` match. +enum class PlayerIntentType { + Enqueue, + EnqueueNext, + TimestampChange, + AllOthers +} + +/** + * A timestamp on the given was clicked and we should switch the playing stream to it. + */ +@Parcelize +data class TimestampChangeData( + val serviceId: Int, + val url: String, + val seconds: Int +) : Parcelable diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index dc7bdaa92..cb5cb97fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -151,7 +151,9 @@ class PlayerService : MediaBrowserServiceCompat() { } if (p != null) { + val oldPlayerType = p.playerType p.handleIntent(intent) + p.handleIntentPost(oldPlayerType) p.UIs().get(MediaSessionPlayerUi::class) ?.handleMediaButtonIntent(intent) } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java index 171a70395..f74389d79 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java @@ -1,32 +1,7 @@ package org.schabi.newpipe.player; -import static org.schabi.newpipe.player.Player.PLAYER_TYPE; - -import android.content.Intent; - public enum PlayerType { MAIN, AUDIO, POPUP; - - /** - * @return an integer representing this {@link PlayerType}, to be used to save it in intents - * @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type - * integers from an intent - */ - public int valueForIntent() { - return ordinal(); - } - - /** - * @param intent the intent to retrieve a player type from - * @return the player type integer retrieved from the intent, converted back into a {@link - * PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the - * intent - * @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer - * @see #valueForIntent() Use valueForIntent() to obtain valid player type integers - */ - public static PlayerType retrieveFromIntent(final Intent intent) { - return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())]; - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 266d65f36..3c69ff78b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -1,8 +1,5 @@ package org.schabi.newpipe.player.helper; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI; @@ -25,7 +22,6 @@ import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; @@ -410,23 +406,9 @@ public final class PlayerHelper { return singlePlayQueue; } - // endregion // region Utils used by player - @RepeatMode - public static int nextRepeatMode(@RepeatMode final int repeatMode) { - switch (repeatMode) { - case REPEAT_MODE_OFF: - return REPEAT_MODE_ONE; - case REPEAT_MODE_ONE: - return REPEAT_MODE_ALL; - case REPEAT_MODE_ALL: - default: - return REPEAT_MODE_OFF; - } - } - @ResizeMode public static int retrieveResizeModeFromPrefs(final Player player) { return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode), diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 0894d22be..7964b13cf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -23,6 +23,7 @@ import androidx.core.content.ContextCompat; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerIntentType; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.util.NavigationHelper; @@ -254,7 +255,9 @@ public final class NotificationUtil { } else { // We are playing in fragment. Don't open another activity just show fragment. That's it final Intent intent = NavigationHelper.getPlayerIntent( - player.getContext(), MainActivity.class, null, true); + player.getContext(), MainActivity.class, null, + PlayerIntentType.AllOthers); + intent.putExtra(Player.RESUME_PLAYBACK, true); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setAction(Intent.ACTION_MAIN); intent.addCategory(Intent.CATEGORY_LAUNCHER); 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 index 1ae7e5cdb..ab7dee2d0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt @@ -254,6 +254,22 @@ abstract class PlayQueue internal constructor( broadcast(AppendEvent(itemList.size)) } + /** + * Add the given item after the current stream. + * + * @param item item to add. + * @param skipIfSame if set, skip adding if the next stream is the same stream. + */ + fun enqueueNext(item: PlayQueueItem, skipIfSame: Boolean) { + val currentIndex = index + // if the next item is the same item as the one we want to enqueue, skip if flag is true + if (skipIfSame && item == getItem(currentIndex + 1)) { + return + } + append(listOf(item)) + move(size() - 1, currentIndex + 1) + } + /** * Removes the item at the given index from the play queue. * diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java index 0eb0f235a..f13d7924d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java @@ -16,7 +16,9 @@ public final class SinglePlayQueue extends PlayQueue { public SinglePlayQueue(final StreamInfo info) { super(0, List.of(new PlayQueueItem(info))); } - + public SinglePlayQueue(final PlayQueueItem item) { + super(0, List.of(item)); + } public SinglePlayQueue(final StreamInfo info, final long startPosition) { super(0, List.of(new PlayQueueItem(info))); getItem().setRecoveryPosition(startPosition); diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index c71836609..cc260d254 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -58,8 +58,10 @@ import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; import org.schabi.newpipe.player.PlayQueueActivity; import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerIntentType; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerType; +import org.schabi.newpipe.player.TimestampChangeData; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -69,6 +71,7 @@ import org.schabi.newpipe.settings.SettingsV2Activity; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; +import java.util.Optional; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -87,54 +90,32 @@ public final class NavigationHelper { public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @Nullable final PlayQueue playQueue, - final boolean resumePlayback) { - final Intent intent = new Intent(context, targetClazz); - - if (playQueue != null) { - final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); - if (cacheKey != null) { - intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); - } - } - intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent()); - intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback); - intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true); - - return intent; + @NonNull final PlayerIntentType playerIntentType) { + final String cacheKey = Optional.ofNullable(playQueue) + .map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class)) + .orElse(null); + return new Intent(context, targetClazz) + .putExtra(Player.PLAY_QUEUE_KEY, cacheKey) + .putExtra(Player.PLAYER_TYPE, PlayerType.MAIN) + .putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) + .putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType); } @NonNull - public static Intent getPlayerIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue, - final boolean resumePlayback, - final boolean playWhenReady) { - return getPlayerIntent(context, targetClazz, playQueue, resumePlayback) - .putExtra(Player.PLAY_WHEN_READY, playWhenReady); - } - - @NonNull - public static Intent getPlayerEnqueueIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue) { - // when enqueueing `resumePlayback` is always `false` since: - // - if there is a video already playing, the value of `resumePlayback` just doesn't make - // any difference. - // - if there is nothing already playing, it is useful for the enqueue action to have a - // slightly different behaviour than the normal play action: the latter resumes playback, - // the former doesn't. (note that enqueue can be triggered when nothing is playing only - // by long pressing the video detail fragment, playlist or channel controls - return getPlayerIntent(context, targetClazz, playQueue, false) - .putExtra(Player.ENQUEUE, true); + public static Intent getPlayerTimestampIntent(@NonNull final Context context, + @NonNull final TimestampChangeData data) { + return new Intent(context, PlayerService.class) + .putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange) + .putExtra(Player.PLAYER_INTENT_DATA, data); } @NonNull public static Intent getPlayerEnqueueNextIntent(@NonNull final Context context, @NonNull final Class targetClazz, @Nullable final PlayQueue playQueue) { - // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false - return getPlayerIntent(context, targetClazz, playQueue, false) - .putExtra(Player.ENQUEUE_NEXT, true); + return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext) + // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false + .putExtra(Player.RESUME_PLAYBACK, false); } /* PLAY */ @@ -168,8 +149,10 @@ public final class NavigationHelper { Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); - intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent()); + final var intent = getPlayerIntent(context, PlayerService.class, queue, + PlayerIntentType.AllOthers) + .putExtra(Player.PLAYER_TYPE, PlayerType.POPUP) + .putExtra(Player.RESUME_PLAYBACK, resumePlayback); ContextCompat.startForegroundService(context, intent); } @@ -179,8 +162,10 @@ public final class NavigationHelper { Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) .show(); - final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); - intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent()); + final Intent intent = getPlayerIntent(context, PlayerService.class, queue, + PlayerIntentType.AllOthers) + .putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO) + .putExtra(Player.RESUME_PLAYBACK, resumePlayback); ContextCompat.startForegroundService(context, intent); } @@ -193,9 +178,18 @@ public final class NavigationHelper { } Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue); - intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); + // when enqueueing `resumePlayback` is always `false` since: + // - if there is a video already playing, the value of `resumePlayback` just doesn't make + // any difference. + // - if there is nothing already playing, it is useful for the enqueue action to have a + // slightly different behaviour than the normal play action: the latter resumes playback, + // the former doesn't. (note that enqueue can be triggered when nothing is playing only + // by long pressing the video detail fragment, playlist or channel controls + final Intent intent = getPlayerIntent(context, PlayerService.class, queue, + PlayerIntentType.Enqueue) + .putExtra(Player.RESUME_PLAYBACK, false) + .putExtra(Player.PLAYER_TYPE, playerType); ContextCompat.startForegroundService(context, intent); } @@ -217,9 +211,8 @@ public final class NavigationHelper { playerType = PlayerType.AUDIO; } Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue); - - intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); + final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue) + .putExtra(Player.PLAYER_TYPE, playerType); ContextCompat.startForegroundService(context, intent); } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java index 2e4aa320f..3288b4347 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java @@ -1,63 +1,27 @@ package org.schabi.newpipe.util.text; import android.content.Context; +import android.content.Intent; +import androidx.core.content.ContextCompat; import androidx.annotation.NonNull; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.player.TimestampChangeData; import org.schabi.newpipe.util.NavigationHelper; import java.util.regex.Matcher; import java.util.regex.Pattern; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - public final class InternalUrlsHandler { - private static final String TAG = InternalUrlsHandler.class.getSimpleName(); - private static final boolean DEBUG = MainActivity.DEBUG; - private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); - private static final Pattern HASHTAG_TIMESTAMP_PATTERN = - Pattern.compile("(.*)#timestamp=(\\d+)"); private InternalUrlsHandler() { } - /** - * Handle a YouTube timestamp comment URL in NewPipe. - *

- * This method will check if the provided url is a YouTube comment description URL ({@code - * https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the - * popup player will be opened when the user will click on the timestamp in the comment, - * at the time and for the video indicated in the timestamp. - * - * @param disposables a field of the Activity/Fragment class that calls this method - * @param context the context to use - * @param url the URL to check if it can be handled - * @return true if the URL can be handled by NewPipe, false if it cannot - */ - public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable - disposables, - final Context context, - @NonNull final String url) { - return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables); - } - /** * Handle a YouTube timestamp description URL in NewPipe. *

@@ -66,36 +30,13 @@ public final class InternalUrlsHandler { * player will be opened when the user will click on the timestamp in the video description, * at the time and for the video indicated in the timestamp. * - * @param disposables a field of the Activity/Fragment class that calls this method * @param context the context to use * @param url the URL to check if it can be handled * @return true if the URL can be handled by NewPipe, false if it cannot */ - public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable - disposables, - final Context context, + public static boolean handleUrlDescriptionTimestamp(final Context context, @NonNull final String url) { - return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); - } - - /** - * Handle an URL in NewPipe. - *

- * This method will check if the provided url can be handled in NewPipe or not. If this is a - * service URL with a timestamp, the popup player will be opened and true will be returned; - * else, false will be returned. - * - * @param context the context to use - * @param url the URL to check if it can be handled - * @param pattern the pattern to use - * @param disposables a field of the Activity/Fragment class that calls this method - * @return true if the URL can be handled by NewPipe, false if it cannot - */ - private static boolean handleUrl(final Context context, - @NonNull final String url, - @NonNull final Pattern pattern, - @NonNull final CompositeDisposable disposables) { - final Matcher matcher = pattern.matcher(url); + final Matcher matcher = AMPERSAND_TIMESTAMP_PATTERN.matcher(url); if (!matcher.matches()) { return false; } @@ -120,7 +61,7 @@ public final class InternalUrlsHandler { } if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { - return playOnPopup(context, matchedUrl, service, seconds, disposables); + return playOnPopup(context, matchedUrl, service, seconds); } else { NavigationHelper.openRouterActivity(context, matchedUrl); return true; @@ -134,15 +75,12 @@ public final class InternalUrlsHandler { * @param url the URL of the content * @param service the service of the content * @param seconds the position in seconds at which the floating player will start - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class * @return true if the playback of the content has successfully started or false if not */ public static boolean playOnPopup(final Context context, final String url, @NonNull final StreamingService service, - final int seconds, - @NonNull final CompositeDisposable disposables) { + final int seconds) { final LinkHandlerFactory factory = service.getStreamLHFactory(); final String cleanUrl; @@ -152,19 +90,14 @@ public final class InternalUrlsHandler { return false; } - final Single single = - ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); - disposables.add(single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final PlayQueue playQueue = new SinglePlayQueue(info, seconds * 1000L); - NavigationHelper.playOnPopupPlayer(context, playQueue, false); - }, throwable -> { - // This will only show a snackbar if the passed context has a root view: - // otherwise it will resort to showing a notification, so we are safe here. - ErrorUtil.showSnackbar(context, - new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, url, null, url)); - })); + final Intent intent = NavigationHelper.getPlayerTimestampIntent(context, + new TimestampChangeData( + service.getServiceId(), + cleanUrl, + seconds + )); + ContextCompat.startForegroundService(context, intent); + return true; } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java index 1419ac85a..4221da398 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java @@ -192,7 +192,7 @@ public final class TextLinkifier { *

* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of * a content, this method will parse the {@link CharSequence} and replace all current web links - * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. + * with {@link ShareUtils#openUrlInBrowser(Context, String)}. *

* *

@@ -240,7 +240,7 @@ public final class TextLinkifier { for (final URLSpan span : urls) { final String url = span.getURL(); final LongPressClickableSpan longPressClickableSpan = - new UrlLongPressClickableSpan(context, disposables, url); + new UrlLongPressClickableSpan(context, url); textBlockLinked.setSpan(longPressClickableSpan, textBlockLinked.getSpanStart(span), diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java index f5864794a..35a9fd996 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java @@ -46,7 +46,7 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan { @Override public void onClick(@NonNull final View view) { playOnPopup(context, relatedStreamUrl, relatedInfoService, - timestampMatchDTO.seconds(), disposables); + timestampMatchDTO.seconds()); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java index 61c1a546d..ec3cefc62 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java @@ -7,29 +7,22 @@ import androidx.annotation.NonNull; import org.schabi.newpipe.util.external_communication.ShareUtils; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - final class UrlLongPressClickableSpan extends LongPressClickableSpan { @NonNull private final Context context; @NonNull - private final CompositeDisposable disposables; - @NonNull private final String url; UrlLongPressClickableSpan(@NonNull final Context context, - @NonNull final CompositeDisposable disposables, @NonNull final String url) { this.context = context; - this.disposables = disposables; this.url = url; } @Override public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( - disposables, context, url)) { + if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) { ShareUtils.openUrlInApp(context, url); } }