From 0c17f0825b6f8b23ccba2c845fa5275754b29848 Mon Sep 17 00:00:00 2001
From: John Zhen Mo <zhenmogukl@gmail.com>
Date: Sat, 3 Mar 2018 11:42:23 -0800
Subject: [PATCH] -Added loader eviction to avoid spawning too many threads in
 MediaSourceManager. -Added nonnull and final constraints to variables in
 MediaSourceManager. -Added nonnull and final constraints on context related
 objects in BasePlayer. -Fixed Hls livestreams crashing player when behind
 live window for too long. -Fixed cache miss when InfoCache key mismatch
 between StreamInfo and StreamInfoItem.

---
 app/build.gradle                              |   2 +-
 .../fragments/detail/VideoDetailFragment.java |   2 +-
 .../newpipe/player/BackgroundPlayer.java      |  11 +-
 .../org/schabi/newpipe/player/BasePlayer.java | 363 +++++++++++-------
 .../newpipe/player/PopupVideoPlayer.java      |  10 +-
 .../schabi/newpipe/player/VideoPlayer.java    |   7 +-
 .../player/playback/MediaSourceManager.java   | 209 +++++-----
 .../newpipe/playlist/PlayQueueAdapter.java    |  29 +-
 .../schabi/newpipe/util/ExtractorHelper.java  |   2 +-
 .../org/schabi/newpipe/util/InfoCache.java    |  37 +-
 10 files changed, 395 insertions(+), 277 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index bfc22c76b..74a005ce3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -55,7 +55,7 @@ dependencies {
         exclude module: 'support-annotations'
     }
 
-    implementation 'com.github.karyogamy:NewPipeExtractor:4cf4ee394f'
+    implementation 'com.github.karyogamy:NewPipeExtractor:b4206479cb'
 
     testImplementation 'junit:junit:4.12'
     testImplementation 'org.mockito:mockito-core:1.10.19'
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 b306721ba..6d505b00e 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
@@ -322,7 +322,7 @@ public class VideoDetailFragment
         if (serializable instanceof StreamInfo) {
             //noinspection unchecked
             currentInfo = (StreamInfo) serializable;
-            InfoCache.getInstance().putInfo(currentInfo);
+            InfoCache.getInstance().putInfo(serviceId, url, currentInfo);
         }
 
         serializable = savedState.getSerializable(STACK_KEY);
diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
index 7e5e612d6..f002115f8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
@@ -33,6 +33,7 @@ import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.v4.app.NotificationCompat;
 import android.util.Log;
+import android.view.View;
 import android.widget.RemoteViews;
 
 import com.google.android.exoplayer2.PlaybackParameters;
@@ -292,15 +293,15 @@ public final class BackgroundPlayer extends Service {
         }
 
         @Override
-        public void onThumbnailReceived(Bitmap thumbnail) {
-            super.onThumbnailReceived(thumbnail);
+        public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
+            super.onLoadingComplete(imageUri, view, loadedImage);
 
-            if (thumbnail != null) {
+            if (loadedImage != null) {
                 // rebuild notification here since remote view does not release bitmaps, causing memory leaks
                 resetNotification();
 
-                if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
-                if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
+                if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
+                if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
 
                 updateNotification(-1);
             }
diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
index 6a867110a..86a4d1234 100644
--- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
@@ -43,6 +43,7 @@ import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.RenderersFactory;
 import com.google.android.exoplayer2.SimpleExoPlayer;
 import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.BehindLiveWindowException;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.TrackGroupArray;
 import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
@@ -50,7 +51,8 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
 import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
 import com.google.android.exoplayer2.util.Util;
 import com.nostra13.universalimageloader.core.ImageLoader;
-import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener;
+import com.nostra13.universalimageloader.core.assist.FailReason;
+import com.nostra13.universalimageloader.core.listener.ImageLoadingListener;
 
 import org.schabi.newpipe.Downloader;
 import org.schabi.newpipe.R;
@@ -67,6 +69,8 @@ import org.schabi.newpipe.playlist.PlayQueueAdapter;
 import org.schabi.newpipe.playlist.PlayQueueItem;
 import org.schabi.newpipe.util.SerializedCache;
 
+import java.io.IOException;
+import java.net.UnknownHostException;
 import java.util.concurrent.TimeUnit;
 
 import io.reactivex.Observable;
@@ -86,17 +90,18 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
  * @author mauriciocolli
  */
 @SuppressWarnings({"WeakerAccess"})
-public abstract class BasePlayer implements Player.EventListener, PlaybackListener {
+public abstract class BasePlayer implements
+        Player.EventListener, PlaybackListener, ImageLoadingListener {
 
     public static final boolean DEBUG = true;
-    public static final String TAG = "BasePlayer";
+    @NonNull public static final String TAG = "BasePlayer";
 
-    protected Context context;
+    @NonNull final protected Context context;
 
-    protected BroadcastReceiver broadcastReceiver;
-    protected IntentFilter intentFilter;
+    @NonNull final protected BroadcastReceiver broadcastReceiver;
+    @NonNull final protected IntentFilter intentFilter;
 
-    protected PlayQueueAdapter playQueueAdapter;
+    @NonNull final protected HistoryRecordManager recordManager;
 
     /*//////////////////////////////////////////////////////////////////////////
     // Intent
@@ -117,8 +122,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     protected static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f};
     protected static final float[] PLAYBACK_PITCHES = {0.8f, 0.9f, 0.95f, 1f, 1.05f, 1.1f, 1.2f};
 
-    protected MediaSourceManager playbackManager;
     protected PlayQueue playQueue;
+    protected PlayQueueAdapter playQueueAdapter;
+
+    protected MediaSourceManager playbackManager;
 
     protected StreamInfo currentInfo;
     protected PlayQueueItem currentItem;
@@ -134,23 +141,20 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     protected final static int PROGRESS_LOOP_INTERVAL = 500;
     protected final static int RECOVERY_SKIP_THRESHOLD = 3000; // 3 seconds
 
+    protected CustomTrackSelector trackSelector;
+    protected PlayerDataSource dataSource;
+
     protected SimpleExoPlayer simpleExoPlayer;
     protected AudioReactor audioReactor;
 
     protected boolean isPrepared = false;
 
-    protected CustomTrackSelector trackSelector;
-
-    protected PlayerDataSource dataSource;
-
     protected Disposable progressUpdateReactor;
     protected CompositeDisposable databaseUpdateReactor;
 
-    protected HistoryRecordManager recordManager;
-
     //////////////////////////////////////////////////////////////////////////*/
 
-    public BasePlayer(Context context) {
+    public BasePlayer(@NonNull final Context context) {
         this.context = context;
 
         this.broadcastReceiver = new BroadcastReceiver() {
@@ -162,6 +166,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         this.intentFilter = new IntentFilter();
         setupBroadcastReceiver(intentFilter);
         context.registerReceiver(broadcastReceiver, intentFilter);
+
+        this.recordManager = new HistoryRecordManager(context);
     }
 
     public void setup() {
@@ -172,7 +178,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     public void initPlayer() {
         if (DEBUG) Log.d(TAG, "initPlayer() called with: context = [" + context + "]");
 
-        if (recordManager == null) recordManager = new HistoryRecordManager(context);
         if (databaseUpdateReactor != null) databaseUpdateReactor.dispose();
         databaseUpdateReactor = new CompositeDisposable();
 
@@ -195,13 +200,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
 
     public void initListeners() {}
 
-    private Disposable getProgressReactor() {
-        return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS)
-                .observeOn(AndroidSchedulers.mainThread())
-                .filter(ignored -> isProgressLoopRunning())
-                .subscribe(ignored -> triggerProgressUpdate());
-    }
-
     public void handleIntent(Intent intent) {
         if (DEBUG) Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]");
         if (intent == null) return;
@@ -217,7 +215,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
             int sizeBeforeAppend = playQueue.size();
             playQueue.append(queue.getStreams());
 
-            if (intent.getBooleanExtra(SELECT_ON_APPEND, false) && queue.getStreams().size() > 0) {
+            if (intent.getBooleanExtra(SELECT_ON_APPEND, false) &&
+                    queue.getStreams().size() > 0) {
                 playQueue.setIndex(sizeBeforeAppend);
             }
 
@@ -247,24 +246,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         playQueueAdapter = new PlayQueueAdapter(context, playQueue);
     }
 
-    public void initThumbnail(final String url) {
-        if (DEBUG) Log.d(TAG, "initThumbnail() called");
-        if (url == null || url.isEmpty()) return;
-        ImageLoader.getInstance().resume();
-        ImageLoader.getInstance().loadImage(url, new SimpleImageLoadingListener() {
-            @Override
-            public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
-                if (simpleExoPlayer == null) return;
-                if (DEBUG) Log.d(TAG, "onLoadingComplete() called with: imageUri = [" + imageUri + "], view = [" + view + "], loadedImage = [" + loadedImage + "]");
-                onThumbnailReceived(loadedImage);
-            }
-        });
-    }
-
-    public void onThumbnailReceived(Bitmap thumbnail) {
-        if (DEBUG) Log.d(TAG, "onThumbnailReceived() called with: thumbnail = [" + thumbnail + "]");
-    }
-
     public void destroyPlayer() {
         if (DEBUG) Log.d(TAG, "destroyPlayer() called");
         if (simpleExoPlayer != null) {
@@ -292,7 +273,46 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
 
         trackSelector = null;
         simpleExoPlayer = null;
-        recordManager = null;
+    }
+
+    /*//////////////////////////////////////////////////////////////////////////
+    // Thumbnail Loading
+    //////////////////////////////////////////////////////////////////////////*/
+
+    public void initThumbnail(final String url) {
+        if (DEBUG) Log.d(TAG, "Thumbnail - initThumbnail() called");
+        if (url == null || url.isEmpty()) return;
+        ImageLoader.getInstance().resume();
+        ImageLoader.getInstance().loadImage(url, this);
+    }
+
+    @Override
+    public void onLoadingStarted(String imageUri, View view) {
+        if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingStarted() called on: " +
+                "imageUri = [" + imageUri + "], view = [" + view + "]");
+    }
+
+    @Override
+    public void onLoadingFailed(String imageUri, View view, FailReason failReason) {
+        Log.e(TAG, "Thumbnail - onLoadingFailed() called on imageUri = [" + imageUri + "]",
+                failReason.getCause());
+    }
+
+    @Override
+    public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
+        if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingComplete() called with: " +
+                "imageUri = [" + imageUri + "], view = [" + view + "], " +
+                "loadedImage = [" + loadedImage + "]");
+    }
+
+    @Override
+    public void onLoadingCancelled(String imageUri, View view) {
+        if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingCancelled() called with: " +
+                "imageUri = [" + imageUri + "], view = [" + view + "]");
+    }
+
+    protected void clearThumbnailCache() {
+        ImageLoader.getInstance().clearMemoryCache();
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -371,9 +391,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     }
 
     public void unregisterBroadcastReceiver() {
-        if (broadcastReceiver != null && context != null) {
+        try {
             context.unregisterReceiver(broadcastReceiver);
-            broadcastReceiver = null;
+        } catch (final IllegalArgumentException unregisteredException) {
+            Log.e(TAG, "Broadcast receiver already unregistered.", unregisteredException);
         }
     }
 
@@ -423,6 +444,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     public void onPlaying() {
         if (DEBUG) Log.d(TAG, "onPlaying() called");
         if (!isProgressLoopRunning()) startProgressLoop();
+        if (!isCurrentWindowValid()) seekToDefault();
     }
 
     public void onBuffering() {
@@ -480,64 +502,95 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         }
     }
 
+    /*//////////////////////////////////////////////////////////////////////////
+    // Progress Updates
+    //////////////////////////////////////////////////////////////////////////*/
+
+    public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent);
+
+    protected void startProgressLoop() {
+        if (progressUpdateReactor != null) progressUpdateReactor.dispose();
+        progressUpdateReactor = getProgressReactor();
+    }
+
+    protected void stopProgressLoop() {
+        if (progressUpdateReactor != null) progressUpdateReactor.dispose();
+        progressUpdateReactor = null;
+    }
+
+    public void triggerProgressUpdate() {
+        onUpdateProgress(
+                (int) simpleExoPlayer.getCurrentPosition(),
+                (int) simpleExoPlayer.getDuration(),
+                simpleExoPlayer.getBufferedPercentage()
+        );
+    }
+
+
+    private Disposable getProgressReactor() {
+        return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS)
+                .observeOn(AndroidSchedulers.mainThread())
+                .filter(ignored -> isProgressLoopRunning())
+                .subscribe(ignored -> triggerProgressUpdate());
+    }
+
     /*//////////////////////////////////////////////////////////////////////////
     // ExoPlayer Listener
     //////////////////////////////////////////////////////////////////////////*/
 
-    private void maybeRecover() {
-        final int currentSourceIndex = playQueue.getIndex();
-        final PlayQueueItem currentSourceItem = playQueue.getItem();
+    @Override
+    public void onTimelineChanged(Timeline timeline, Object manifest,
+                                  @Player.TimelineChangeReason final int reason) {
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " +
+                (manifest == null ? "no manifest" : "available manifest") + ", " +
+                "timeline size = [" + timeline.getWindowCount() + "], " +
+                "reason = [" + reason + "]");
 
-        // Check if already playing correct window
-        final boolean isCurrentPeriodCorrect =
-                simpleExoPlayer.getCurrentPeriodIndex() == currentSourceIndex;
-
-        // Check if recovering
-        if (isCurrentPeriodCorrect && currentSourceItem != null) {
-            /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer,
-             * rounding this position to the nearest second will help alleviate this.*/
-            final long position = currentSourceItem.getRecoveryPosition();
-
-            /* Skip recovering if the recovery position is not set.*/
-            if (position == PlayQueueItem.RECOVERY_UNSET) return;
-
-            if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex +
-                    " at: " + getTimeString((int)position));
-            simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition());
-            playQueue.unsetRecovery(currentSourceIndex);
+        switch (reason) {
+            case Player.TIMELINE_CHANGE_REASON_RESET: // called after #block
+            case Player.TIMELINE_CHANGE_REASON_PREPARED: // called after #unblock
+            case Player.TIMELINE_CHANGE_REASON_DYNAMIC: // called after playlist changes
+                if (playQueue != null && playbackManager != null &&
+                        // ensures MediaSourceManager#update is complete
+                        timeline.getWindowCount() == playQueue.size()) {
+                    playbackManager.load();
+                }
         }
     }
 
-    @Override
-    public void onTimelineChanged(Timeline timeline, Object manifest, int reason) {
-        if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount());
-    }
-
     @Override
     public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
-        if (DEBUG) Log.d(TAG, "onTracksChanged(), track group size = " + trackGroups.length);
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onTracksChanged(), " +
+                "track group size = " + trackGroups.length);
     }
 
     @Override
     public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
-        if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed +
-                ", pitch: " + playbackParameters.pitch);
+        if (DEBUG) Log.d(TAG, "ExoPlayer - playbackParameters(), " +
+                "speed: " + playbackParameters.speed + ", " +
+                "pitch: " + playbackParameters.pitch);
     }
 
     @Override
-    public void onLoadingChanged(boolean isLoading) {
-        if (DEBUG) Log.d(TAG, "onLoadingChanged() called with: isLoading = [" + isLoading + "]");
+    public void onLoadingChanged(final boolean isLoading) {
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " +
+                "isLoading = [" + isLoading + "]");
 
-        if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) stopProgressLoop();
-        else if (isLoading && !isProgressLoopRunning()) startProgressLoop();
+        if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) {
+            stopProgressLoop();
+        } else if (isLoading && !isProgressLoopRunning()) {
+            startProgressLoop();
+        }
     }
 
     @Override
     public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
-        if (DEBUG)
-            Log.d(TAG, "onPlayerStateChanged() called with: playWhenReady = [" + playWhenReady + "], playbackState = [" + playbackState + "]");
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " +
+                "playWhenReady = [" + playWhenReady + "], " +
+                "playbackState = [" + playbackState + "]");
+
         if (getCurrentState() == STATE_PAUSED_SEEK) {
-            if (DEBUG) Log.d(TAG, "onPlayerStateChanged() is currently blocked");
+            if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked");
             return;
         }
 
@@ -572,24 +625,35 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         }
     }
 
+    private void maybeRecover() {
+        final int currentSourceIndex = playQueue.getIndex();
+        final PlayQueueItem currentSourceItem = playQueue.getItem();
+
+        // Check if already playing correct window
+        final boolean isCurrentPeriodCorrect =
+                simpleExoPlayer.getCurrentPeriodIndex() == currentSourceIndex;
+
+        // Check if recovering
+        if (isCurrentPeriodCorrect && currentSourceItem != null) {
+            /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer,
+             * rounding this position to the nearest second will help alleviate this.*/
+            final long position = currentSourceItem.getRecoveryPosition();
+
+            /* Skip recovering if the recovery position is not set.*/
+            if (position == PlayQueueItem.RECOVERY_UNSET) return;
+
+            if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex +
+                    " at: " + getTimeString((int)position));
+            simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition());
+            playQueue.unsetRecovery(currentSourceIndex);
+        }
+    }
+
     /**
      * Processes the exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}.
      * There are multiple types of errors: <br><br>
      *
      * {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}: <br><br>
-     * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid,
-     * then we know the error is produced by transitioning into a bad window, therefore we report
-     * an error to the play queue based on if the current error can be skipped.
-     *
-     * This is done because ExoPlayer reports the source exceptions before window is
-     * transitioned on seamless playback. Because player error causes ExoPlayer to go
-     * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source
-     * again to resume playback.
-     *
-     * In the event that this error is produced during a valid stream playback, we save the
-     * current position so the playback may be recovered and resumed manually by the user. This
-     * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete.
-     * <br><br>
      *
      * {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}: <br><br>
      * If a runtime error occurred, then we can try to recover it by restarting the playback
@@ -598,11 +662,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
      * {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}: <br><br>
      * If the renderer failed, treat the error as unrecoverable.
      *
+     * @see #processSourceError(IOException)
      * @see Player.EventListener#onPlayerError(ExoPlaybackException)
      *  */
     @Override
     public void onPlayerError(ExoPlaybackException error) {
-        if (DEBUG) Log.d(TAG, "onPlayerError() called with: error = [" + error + "]");
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerError() called with: " +
+                "error = [" + error + "]");
         if (errorToast != null) {
             errorToast.cancel();
             errorToast = null;
@@ -612,11 +678,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
 
         switch (error.type) {
             case ExoPlaybackException.TYPE_SOURCE:
-                if (simpleExoPlayer.getCurrentPosition() <
-                        simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) {
-                    setRecovery();
-                }
-                playQueue.error(isCurrentWindowValid());
+                processSourceError(error.getSourceException());
                 showStreamError(error);
                 break;
             case ExoPlaybackException.TYPE_UNEXPECTED:
@@ -631,9 +693,48 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         }
     }
 
+    /**
+     * Processes {@link ExoPlaybackException} tagged with {@link ExoPlaybackException#TYPE_SOURCE}.
+     * <br><br>
+     * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid,
+     * then we know the error is produced by transitioning into a bad window, therefore we report
+     * an error to the play queue based on if the current error can be skipped.
+     * <br><br>
+     * This is done because ExoPlayer reports the source exceptions before window is
+     * transitioned on seamless playback. Because player error causes ExoPlayer to go
+     * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source
+     * again to resume playback.
+     * <br><br>
+     * In the event that this error is produced during a valid stream playback, we save the
+     * current position so the playback may be recovered and resumed manually by the user. This
+     * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete.
+     * <br><br>
+     * In the event of livestreaming being lagged behind for any reason, most notably pausing for
+     * too long, a {@link BehindLiveWindowException} will be produced. This will trigger a reload
+     * instead of skipping or removal.
+     * */
+    private void processSourceError(final IOException error) {
+        if (simpleExoPlayer == null || playQueue == null) return;
+
+        if (simpleExoPlayer.getCurrentPosition() <
+                simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) {
+            setRecovery();
+        }
+
+        final Throwable cause = error.getCause();
+        if (cause instanceof BehindLiveWindowException) {
+            reload();
+        } else if (cause instanceof UnknownHostException) {
+            playQueue.error(/*isNetworkProblem=*/true);
+        } else {
+            playQueue.error(isCurrentWindowValid());
+        }
+    }
+
     @Override
-    public void onPositionDiscontinuity(int reason) {
-        if (DEBUG) Log.d(TAG, "onPositionDiscontinuity() called with reason = [" + reason + "]");
+    public void onPositionDiscontinuity(@Player.DiscontinuityReason final int reason) {
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " +
+                "reason = [" + reason + "]");
         // Refresh the playback if there is a transition to the next video
         final int newPeriodIndex = simpleExoPlayer.getCurrentPeriodIndex();
 
@@ -645,30 +746,28 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
                 } else {
                     playQueue.offsetIndex(+1);
                 }
-                playbackManager.load();
-                break;
             case DISCONTINUITY_REASON_SEEK:
             case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
             case DISCONTINUITY_REASON_INTERNAL:
-            default:
                 break;
         }
     }
 
     @Override
-    public void onRepeatModeChanged(int i) {
-        if (DEBUG) Log.d(TAG, "onRepeatModeChanged() called with: mode = [" + i + "]");
+    public void onRepeatModeChanged(@Player.RepeatMode final int reason) {
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " +
+                "mode = [" + reason + "]");
     }
 
     @Override
-    public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
-        if (DEBUG) Log.d(TAG, "onShuffleModeEnabledChanged() called with: " +
+    public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " +
                 "mode = [" + shuffleModeEnabled + "]");
     }
 
     @Override
     public void onSeekProcessed() {
-        if (DEBUG) Log.d(TAG, "onSeekProcessed() called");
+        if (DEBUG) Log.d(TAG, "ExoPlayer - onSeekProcessed() called");
     }
     /*//////////////////////////////////////////////////////////////////////////
     // Playback Listener
@@ -677,7 +776,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     @Override
     public void block() {
         if (simpleExoPlayer == null) return;
-        if (DEBUG) Log.d(TAG, "Blocking...");
+        if (DEBUG) Log.d(TAG, "Playback - block() called");
 
         currentItem = null;
         currentInfo = null;
@@ -690,12 +789,12 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     @Override
     public void unblock(final MediaSource mediaSource) {
         if (simpleExoPlayer == null) return;
-        if (DEBUG) Log.d(TAG, "Unblocking...");
+        if (DEBUG) Log.d(TAG, "Playback - unblock() called");
 
         if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING);
 
         simpleExoPlayer.prepare(mediaSource);
-        simpleExoPlayer.seekToDefaultPosition();
+        seekToDefault();
     }
 
     @Override
@@ -705,7 +804,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         currentItem = item;
         currentInfo = info;
 
-        if (DEBUG) Log.d(TAG, "Syncing...");
+        if (DEBUG) Log.d(TAG, "Playback - sync() called with " +
+                (info == null ? "available" : "null") + " info, " +
+                "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]");
         if (simpleExoPlayer == null) return;
 
         // Check if on wrong window
@@ -781,8 +882,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED);
     }
 
-    public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent);
-
     public void onVideoPlayPause() {
         if (DEBUG) Log.d(TAG, "onVideoPlayPause() called");
 
@@ -794,7 +893,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
 
         if (getCurrentState() == STATE_COMPLETED) {
             if (playQueue.getIndex() == 0) {
-                simpleExoPlayer.seekToDefaultPosition();
+                seekToDefault();
             } else {
                 playQueue.setIndex(0);
             }
@@ -839,11 +938,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     }
 
     public void onSelected(final PlayQueueItem item) {
+        if (playQueue == null || simpleExoPlayer == null) return;
+
         final int index = playQueue.indexOf(item);
         if (index == -1) return;
 
-        if (playQueue.getIndex() == index) {
-            simpleExoPlayer.seekToDefaultPosition();
+        if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) {
+            seekToDefault();
         } else {
             playQueue.setIndex(index);
         }
@@ -875,7 +976,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
     //////////////////////////////////////////////////////////////////////////*/
 
     private void registerView() {
-        if (databaseUpdateReactor == null || recordManager == null || currentInfo == null) return;
+        if (databaseUpdateReactor == null || currentInfo == null) return;
         databaseUpdateReactor.add(recordManager.onViewed(currentInfo).onErrorComplete()
                 .subscribe(
                         ignored -> {/* successful */},
@@ -890,30 +991,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen
         }
     }
 
-    protected void clearThumbnailCache() {
-        ImageLoader.getInstance().clearMemoryCache();
-    }
-
-    protected void startProgressLoop() {
-        if (progressUpdateReactor != null) progressUpdateReactor.dispose();
-        progressUpdateReactor = getProgressReactor();
-    }
-
-    protected void stopProgressLoop() {
-        if (progressUpdateReactor != null) progressUpdateReactor.dispose();
-        progressUpdateReactor = null;
-    }
-
-    public void triggerProgressUpdate() {
-        onUpdateProgress(
-                (int) simpleExoPlayer.getCurrentPosition(),
-                (int) simpleExoPlayer.getDuration(),
-                simpleExoPlayer.getBufferedPercentage()
-        );
-    }
-
     protected void savePlaybackState(final StreamInfo info, final long progress) {
-        if (context == null || info == null || databaseUpdateReactor == null) return;
+        if (info == null || databaseUpdateReactor == null) return;
         final Disposable stateSaver = recordManager.saveStreamState(info, progress)
                 .observeOn(AndroidSchedulers.mainThread())
                 .onErrorComplete()
diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
index f4e7a0d6a..6263541bb 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
@@ -419,13 +419,15 @@ public final class PopupVideoPlayer extends Service {
         }
 
         @Override
-        public void onThumbnailReceived(Bitmap thumbnail) {
-            super.onThumbnailReceived(thumbnail);
-            if (thumbnail != null) {
+        public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
+            super.onLoadingComplete(imageUri, view, loadedImage);
+            if (loadedImage != null) {
                 // rebuild notification here since remote view does not release bitmaps, causing memory leaks
                 notBuilder = createNotification();
 
-                if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
+                if (notRemoteView != null) {
+                    notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
+                }
 
                 updateNotification(-1);
             }
diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java
index 5a7a9a462..58de44130 100644
--- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java
@@ -160,7 +160,6 @@ public abstract class VideoPlayer extends BasePlayer
     public VideoPlayer(String debugTag, Context context) {
         super(context);
         this.TAG = debugTag;
-        this.context = context;
     }
 
     public void setup(View rootView) {
@@ -617,9 +616,9 @@ public abstract class VideoPlayer extends BasePlayer
     }
 
     @Override
-    public void onThumbnailReceived(Bitmap thumbnail) {
-        super.onThumbnailReceived(thumbnail);
-        if (thumbnail != null) endScreen.setImageBitmap(thumbnail);
+    public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
+        super.onLoadingComplete(imageUri, view, loadedImage);
+        if (loadedImage != null) endScreen.setImageBitmap(loadedImage);
     }
 
     protected void onFullScreenButtonClicked() {
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 439885e58..bc7f92b42 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
@@ -26,6 +26,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import io.reactivex.Single;
 import io.reactivex.android.schedulers.AndroidSchedulers;
@@ -33,6 +34,7 @@ import io.reactivex.disposables.CompositeDisposable;
 import io.reactivex.disposables.Disposable;
 import io.reactivex.disposables.SerialDisposable;
 import io.reactivex.functions.Consumer;
+import io.reactivex.internal.subscriptions.EmptySubscription;
 import io.reactivex.subjects.PublishSubject;
 
 import static org.schabi.newpipe.playlist.PlayQueue.DEBUG;
@@ -40,66 +42,105 @@ import static org.schabi.newpipe.playlist.PlayQueue.DEBUG;
 public class MediaSourceManager {
     @NonNull private final static String TAG = "MediaSourceManager";
 
-    // WINDOW_SIZE determines how many streams AFTER the current stream should be loaded.
-    // The default value (1) ensures seamless playback under typical network settings.
+    /**
+     * Determines how many streams before and after the current stream should be loaded.
+     * The default value (1) ensures seamless playback under typical network settings.
+     * <br><br>
+     * The streams after the current will be loaded into the playlist timeline while the
+     * streams before will only be cached for future usage.
+     *
+     * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource)
+     * @see #update(int, MediaSource)
+     * */
     private final static int WINDOW_SIZE = 1;
 
     @NonNull private final PlaybackListener playbackListener;
     @NonNull private final PlayQueue playQueue;
 
-    // Once a MediaSource item has been detected to be expired, the manager will immediately
-    // trigger a reload on the associated PlayQueueItem, which may disrupt playback,
-    // if the item is being played
-    private final long expirationTimeMillis;
+    /**
+     * Determines how long NEIGHBOURING {@link LoadedMediaSource} window of a currently playing
+     * {@link MediaSource} is allowed to stay in the playlist timeline. This is to ensure
+     * the {@link StreamInfo} used in subsequent playback is up-to-date.
+     * <br><br>
+     * Once a {@link LoadedMediaSource} has expired, a new source will be reloaded to
+     * replace the expired one on whereupon {@link #loadImmediate()} is called.
+     *
+     * @see #loadImmediate()
+     * @see #isCorrectionNeeded(PlayQueueItem)
+     * */
+    private final long windowRefreshTimeMillis;
 
-    // Process only the last load order when receiving a stream of load orders (lessens I/O)
-    // The higher it is, the less loading occurs during rapid noncritical timeline changes
-    // Not recommended to go below 100ms
+    /**
+     * Process only the last load order when receiving a stream of load orders (lessens I/O).
+     * <br><br>
+     * The higher it is, the less loading occurs during rapid noncritical timeline changes.
+     * <br><br>
+     * Not recommended to go below 100ms.
+     *
+     * @see #loadDebounced()
+     * */
     private final long loadDebounceMillis;
     @NonNull private final Disposable debouncedLoader;
     @NonNull private final PublishSubject<Long> debouncedSignal;
 
-    private DynamicConcatenatingMediaSource sources;
+    @NonNull private Subscription playQueueReactor;
 
-    private Subscription playQueueReactor;
-    private CompositeDisposable loaderReactor;
+    /**
+     * Determines the maximum number of disposables allowed in the {@link #loaderReactor}.
+     * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the
+     * {@link #loaderReactor} in order to load a new set of items.
+     *
+     * @see #loadImmediate()
+     * @see #maybeLoadItem(PlayQueueItem)
+     * */
+    private final static int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1;
+    @NonNull private final CompositeDisposable loaderReactor;
+    @NonNull private Set<PlayQueueItem> loadingItems;
+    @NonNull private final SerialDisposable syncReactor;
 
-    private boolean isBlocked;
+    @NonNull private final AtomicBoolean isBlocked;
 
-    private SerialDisposable syncReactor;
-    private PlayQueueItem syncedItem;
-    private Set<PlayQueueItem> loadingItems;
+    @NonNull private DynamicConcatenatingMediaSource sources;
+
+    @Nullable private PlayQueueItem syncedItem;
 
     public MediaSourceManager(@NonNull final PlaybackListener listener,
                               @NonNull final PlayQueue playQueue) {
         this(listener, playQueue,
                 /*loadDebounceMillis=*/400L,
-                /*expirationTimeMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.MINUTES));
+                /*windowRefreshTimeMillis=*/TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES));
     }
 
     private MediaSourceManager(@NonNull final PlaybackListener listener,
                                @NonNull final PlayQueue playQueue,
                                final long loadDebounceMillis,
-                               final long expirationTimeMillis) {
+                               final long windowRefreshTimeMillis) {
+        if (playQueue.getBroadcastReceiver() == null) {
+            throw new IllegalArgumentException("Play Queue has not been initialized.");
+        }
+
         this.playbackListener = listener;
         this.playQueue = playQueue;
-        this.loadDebounceMillis = loadDebounceMillis;
-        this.expirationTimeMillis = expirationTimeMillis;
 
-        this.loaderReactor = new CompositeDisposable();
+        this.windowRefreshTimeMillis = windowRefreshTimeMillis;
+
+        this.loadDebounceMillis = loadDebounceMillis;
         this.debouncedSignal = PublishSubject.create();
         this.debouncedLoader = getDebouncedLoader();
 
+        this.playQueueReactor = EmptySubscription.INSTANCE;
+        this.loaderReactor = new CompositeDisposable();
+        this.syncReactor = new SerialDisposable();
+
+        this.isBlocked = new AtomicBoolean(false);
+
         this.sources = new DynamicConcatenatingMediaSource();
 
-        this.syncReactor = new SerialDisposable();
         this.loadingItems = Collections.synchronizedSet(new HashSet<>());
 
-        if (playQueue.getBroadcastReceiver() != null) {
-            playQueue.getBroadcastReceiver()
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribe(getReactor());
-        }
+        playQueue.getBroadcastReceiver()
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(getReactor());
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -114,16 +155,12 @@ public class MediaSourceManager {
         debouncedSignal.onComplete();
         debouncedLoader.dispose();
 
-        if (playQueueReactor != null) playQueueReactor.cancel();
-        if (loaderReactor != null) loaderReactor.dispose();
-        if (syncReactor != null) syncReactor.dispose();
-        if (sources != null) sources.releaseSource();
+        playQueueReactor.cancel();
+        loaderReactor.dispose();
+        syncReactor.dispose();
+        sources.releaseSource();
 
-        playQueueReactor = null;
-        loaderReactor = null;
-        syncReactor = null;
         syncedItem = null;
-        sources = null;
     }
 
     /**
@@ -158,14 +195,14 @@ public class MediaSourceManager {
         return new Subscriber<PlayQueueEvent>() {
             @Override
             public void onSubscribe(@NonNull Subscription d) {
-                if (playQueueReactor != null) playQueueReactor.cancel();
+                playQueueReactor.cancel();
                 playQueueReactor = d;
                 playQueueReactor.request(1);
             }
 
             @Override
             public void onNext(@NonNull PlayQueueEvent playQueueMessage) {
-                if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage);
+                onPlayQueueChanged(playQueueMessage);
             }
 
             @Override
@@ -227,7 +264,7 @@ public class MediaSourceManager {
             tryBlock();
             playQueue.fetch();
         }
-        if (playQueueReactor != null) playQueueReactor.request(1);
+        playQueueReactor.request(1);
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -240,7 +277,7 @@ public class MediaSourceManager {
     }
 
     private boolean isPlaybackReady() {
-        if (sources == null || sources.getSize() != playQueue.size()) return false;
+        if (sources.getSize() != playQueue.size()) return false;
 
         final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex());
         final PlayQueueItem playQueueItem = playQueue.getItem();
@@ -256,19 +293,19 @@ public class MediaSourceManager {
     private void tryBlock() {
         if (DEBUG) Log.d(TAG, "tryBlock() called.");
 
-        if (isBlocked) return;
+        if (isBlocked.get()) return;
 
         playbackListener.block();
         resetSources();
 
-        isBlocked = true;
+        isBlocked.set(true);
     }
 
     private void tryUnblock() {
         if (DEBUG) Log.d(TAG, "tryUnblock() called.");
 
-        if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) {
-            isBlocked = false;
+        if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) {
+            isBlocked.set(false);
             playbackListener.unblock(sources);
         }
     }
@@ -281,7 +318,7 @@ public class MediaSourceManager {
         if (DEBUG) Log.d(TAG, "sync() called.");
 
         final PlayQueueItem currentItem = playQueue.getItem();
-        if (isBlocked || currentItem == null) return;
+        if (isBlocked.get() || currentItem == null) return;
 
         final Consumer<StreamInfo> onSuccess = info -> syncInternal(currentItem, info);
         final Consumer<Throwable> onError = throwable -> syncInternal(currentItem, null);
@@ -295,11 +332,11 @@ public class MediaSourceManager {
         }
     }
 
-    private void syncInternal(@android.support.annotation.NonNull final PlayQueueItem item,
+    private void syncInternal(@NonNull final PlayQueueItem item,
                               @Nullable final StreamInfo info) {
         // Ensure the current item is up to date with the play queue
         if (playQueue.getItem() == item && playQueue.getItem() == syncedItem) {
-            playbackListener.sync(syncedItem, info);
+            playbackListener.sync(item, info);
         }
     }
 
@@ -323,6 +360,12 @@ public class MediaSourceManager {
         final int currentIndex = playQueue.getIndex();
         final PlayQueueItem currentItem = playQueue.getItem(currentIndex);
         if (currentItem == null) return;
+
+        // Evict the items being loaded to free up memory
+        if (!loadingItems.contains(currentItem) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) {
+            loaderReactor.clear();
+            loadingItems.clear();
+        }
         maybeLoadItem(currentItem);
 
         // The rest are just for seamless playback
@@ -347,34 +390,17 @@ public class MediaSourceManager {
 
     private void maybeLoadItem(@NonNull final PlayQueueItem item) {
         if (DEBUG) Log.d(TAG, "maybeLoadItem() called.");
-        if (sources == null) return;
-
-        final int index = playQueue.indexOf(item);
-        if (index > sources.getSize() - 1) return;
-
-        final Consumer<ManagedMediaSource> onDone = mediaSource -> {
-            if (DEBUG) Log.d(TAG, " Loaded: [" + item.getTitle() +
-                    "] with url: " + item.getUrl());
-
-            final int itemIndex = playQueue.indexOf(item);
-            // Only update the playlist timeline for items at the current index or after.
-            if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) {
-                update(itemIndex, mediaSource);
-            }
-
-            loadingItems.remove(item);
-            tryUnblock();
-            sync();
-        };
+        if (playQueue.indexOf(item) >= sources.getSize()) return;
 
         if (!loadingItems.contains(item) && isCorrectionNeeded(item)) {
-            if (DEBUG) Log.d(TAG, "Loading: [" + item.getTitle() +
+            if (DEBUG) Log.d(TAG, "MediaSource - Loading: [" + item.getTitle() +
                     "] with url: " + item.getUrl());
 
             loadingItems.add(item);
             final Disposable loader = getLoadedMediaSource(item)
                     .observeOn(AndroidSchedulers.mainThread())
-                    .subscribe(onDone);
+                    /* No exception handling since getLoadedMediaSource guarantees nonnull return */
+                    .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource));
             loaderReactor.add(loader);
         }
 
@@ -392,14 +418,32 @@ public class MediaSourceManager {
                                 ", audio count: " + streamInfo.audio_streams.size() +
                                 ", video count: " + streamInfo.video_only_streams.size() +
                                 streamInfo.video_streams.size());
-                return new FailedMediaSource(stream, new IllegalStateException(exception));
+                return new FailedMediaSource(stream, exception);
             }
 
-            final long expiration = System.currentTimeMillis() + expirationTimeMillis;
+            final long expiration = System.currentTimeMillis() + windowRefreshTimeMillis;
             return new LoadedMediaSource(source, stream, expiration);
         }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable));
     }
 
+    private void onMediaSourceReceived(@NonNull final PlayQueueItem item,
+                                       @NonNull final ManagedMediaSource mediaSource) {
+        if (DEBUG) Log.d(TAG, "MediaSource - Loaded: [" + item.getTitle() +
+                "] with url: " + item.getUrl());
+
+        final int itemIndex = playQueue.indexOf(item);
+        // Only update the playlist timeline for items at the current index or after.
+        if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) {
+            if (DEBUG) Log.d(TAG, "MediaSource - Updating: [" + item.getTitle() +
+                    "] with url: " + item.getUrl());
+            update(itemIndex, mediaSource);
+        }
+
+        loadingItems.remove(item);
+        tryUnblock();
+        sync();
+    }
+
     /**
      * Checks if the corresponding MediaSource in {@link DynamicConcatenatingMediaSource}
      * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback
@@ -411,8 +455,6 @@ public class MediaSourceManager {
      * {@link ManagedMediaSource}.
      * */
     private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) {
-        if (sources == null) return false;
-
         final int index = playQueue.indexOf(item);
         if (index == -1 || index >= sources.getSize()) return false;
 
@@ -432,13 +474,13 @@ public class MediaSourceManager {
     private void resetSources() {
         if (DEBUG) Log.d(TAG, "resetSources() called.");
 
-        if (this.sources != null) this.sources.releaseSource();
+        this.sources.releaseSource();
         this.sources = new DynamicConcatenatingMediaSource();
     }
 
     private void populateSources() {
         if (DEBUG) Log.d(TAG, "populateSources() called.");
-        if (sources == null || sources.getSize() >= playQueue.size()) return;
+        if (sources.getSize() >= playQueue.size()) return;
 
         for (int index = sources.getSize() - 1; index < playQueue.size(); index++) {
             emplace(index, new PlaceholderMediaSource());
@@ -451,12 +493,11 @@ public class MediaSourceManager {
 
     /**
      * Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource}
-     * with position * in respect to the play queue only if no {@link MediaSource}
+     * with position in respect to the play queue only if no {@link MediaSource}
      * already exists at the given index.
      * */
-    private void emplace(final int index, @NonNull final MediaSource source) {
-        if (sources == null) return;
-        if (index < 0 || index < sources.getSize()) return;
+    private synchronized void emplace(final int index, @NonNull final MediaSource source) {
+        if (index < sources.getSize()) return;
 
         sources.addMediaSource(index, source);
     }
@@ -465,8 +506,7 @@ public class MediaSourceManager {
      * Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource}
      * at the given index. If this index is out of bound, then the removal is ignored.
      * */
-    private void remove(final int index) {
-        if (sources == null) return;
+    private synchronized void remove(final int index) {
         if (index < 0 || index > sources.getSize()) return;
 
         sources.removeMediaSource(index);
@@ -477,8 +517,7 @@ public class MediaSourceManager {
      * from the given source index to the target index. If either index is out of bound,
      * then the call is ignored.
      * */
-    private void move(final int source, final int target) {
-        if (sources == null) return;
+    private synchronized void move(final int source, final int target) {
         if (source < 0 || target < 0) return;
         if (source >= sources.getSize() || target >= sources.getSize()) return;
 
@@ -491,15 +530,13 @@ public class MediaSourceManager {
      * then the replacement is ignored.
      * <br><br>
      * Not recommended to use on indices LESS THAN the currently playing index, since
-     * this will modify the playback timeline prior to the index and cause desynchronization
+     * this will modify the playback timeline prior to the index and may cause desynchronization
      * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}.
      * */
     private synchronized void update(final int index, @NonNull final MediaSource source) {
-        if (sources == null) return;
         if (index < 0 || index >= sources.getSize()) return;
 
-        sources.addMediaSource(index + 1, source, () -> {
-            if (sources != null) sources.removeMediaSource(index);
-        });
+        sources.addMediaSource(index + 1, source, () ->
+                sources.removeMediaSource(index));
     }
 }
diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java
index 7c701a637..dd320c2bc 100644
--- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java
@@ -63,22 +63,18 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
     }
 
     public PlayQueueAdapter(final Context context, final PlayQueue playQueue) {
+        if (playQueue.getBroadcastReceiver() == null) {
+            throw new IllegalStateException("Play Queue has not been initialized.");
+        }
+
         this.playQueueItemBuilder = new PlayQueueItemBuilder(context);
         this.playQueue = playQueue;
 
-        startReactor();
+        playQueue.getBroadcastReceiver().toObservable().subscribe(getReactor());
     }
 
-    public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) {
-        playQueueItemBuilder.setOnSelectedListener(listener);
-    }
-
-    public void unsetSelectedListener() {
-        playQueueItemBuilder.setOnSelectedListener(null);
-    }
-
-    private void startReactor() {
-        final Observer<PlayQueueEvent> observer = new Observer<PlayQueueEvent>() {
+    private Observer<PlayQueueEvent> getReactor() {
+        return new Observer<PlayQueueEvent>() {
             @Override
             public void onSubscribe(@NonNull Disposable d) {
                 if (playQueueReactor != null) playQueueReactor.dispose();
@@ -99,9 +95,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
             }
         };
 
-        if (playQueue.getBroadcastReceiver() != null) {
-            playQueue.getBroadcastReceiver().toObservable().subscribe(observer);
-        }
     }
 
     private void onPlayQueueChanged(final PlayQueueEvent message) {
@@ -148,6 +141,14 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
         playQueueReactor = null;
     }
 
+    public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) {
+        playQueueItemBuilder.setOnSelectedListener(listener);
+    }
+
+    public void unsetSelectedListener() {
+        playQueueItemBuilder.setOnSelectedListener(null);
+    }
+
     public void setFooter(View footer) {
         this.footer = footer;
         notifyItemChanged(playQueue.size());
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index 78d6a6318..12c3dc44c 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -172,7 +172,7 @@ public final class ExtractorHelper {
                                                          String url,
                                                          Single<I> loadFromNetwork) {
         checkServiceId(serviceId);
-        loadFromNetwork = loadFromNetwork.doOnSuccess((@NonNull I i) -> cache.putInfo(i));
+        loadFromNetwork = loadFromNetwork.doOnSuccess(info -> cache.putInfo(serviceId, url, info));
 
         Single<I> load;
         if (forceLoad) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
index 0f082cc11..47c45e82a 100644
--- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
+++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
@@ -20,6 +20,7 @@
 package org.schabi.newpipe.util;
 
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.v4.util.LruCache;
 import android.util.Log;
 
@@ -29,6 +30,8 @@ import org.schabi.newpipe.extractor.Info;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
+import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
+
 
 public final class InfoCache {
     private static final boolean DEBUG = MainActivity.DEBUG;
@@ -52,6 +55,7 @@ public final class InfoCache {
         return instance;
     }
 
+    @Nullable
     public Info getFromKey(int serviceId, @NonNull String url) {
         if (DEBUG) Log.d(TAG, "getFromKey() called with: serviceId = [" + serviceId + "], url = [" + url + "]");
         synchronized (lruCache) {
@@ -59,18 +63,19 @@ public final class InfoCache {
         }
     }
 
-    public void putInfo(@NonNull Info info) {
+    public void putInfo(int serviceId, @NonNull String url, @NonNull Info info) {
         if (DEBUG) Log.d(TAG, "putInfo() called with: info = [" + info + "]");
-        synchronized (lruCache) {
-            final CacheData data = new CacheData(info, DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS);
-            lruCache.put(keyOf(info), data);
-        }
-    }
 
-    public void removeInfo(@NonNull Info info) {
-        if (DEBUG) Log.d(TAG, "removeInfo() called with: info = [" + info + "]");
+        final long expirationMillis;
+        if (info.getServiceId() == SoundCloud.getServiceId()) {
+            expirationMillis = TimeUnit.MILLISECONDS.convert(15, TimeUnit.MINUTES);
+        } else {
+            expirationMillis = TimeUnit.MILLISECONDS.convert(DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS);
+        }
+
         synchronized (lruCache) {
-            lruCache.remove(keyOf(info));
+            final CacheData data = new CacheData(info, expirationMillis);
+            lruCache.put(keyOf(serviceId, url), data);
         }
     }
 
@@ -102,10 +107,7 @@ public final class InfoCache {
         }
     }
 
-    private static String keyOf(@NonNull final Info info) {
-        return keyOf(info.getServiceId(), info.getUrl());
-    }
-
+    @NonNull
     private static String keyOf(final int serviceId, @NonNull final String url) {
         return serviceId + url;
     }
@@ -119,6 +121,7 @@ public final class InfoCache {
         }
     }
 
+    @Nullable
     private static Info getInfo(@NonNull final LruCache<String, CacheData> cache,
                                 @NonNull final String key) {
         final CacheData data = cache.get(key);
@@ -136,12 +139,8 @@ public final class InfoCache {
         final private long expireTimestamp;
         final private Info info;
 
-        private CacheData(@NonNull final Info info,
-                          final long timeout,
-                          @NonNull final TimeUnit timeUnit) {
-            this.expireTimestamp = System.currentTimeMillis() +
-                    TimeUnit.MILLISECONDS.convert(timeout, timeUnit);
-
+        private CacheData(@NonNull final Info info, final long timeoutMillis) {
+            this.expireTimestamp = System.currentTimeMillis() + timeoutMillis;
             this.info = info;
         }