relatedItems = info.getRelatedItems();
if (Utils.isNullOrEmpty(relatedItems)) {
return null;
}
@@ -297,7 +297,7 @@ public final class PlayerHelper {
}
public static long getPreferredFileSize() {
- return 512 * 1024L;
+ return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index da1238c81..68de8ce9f 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -8,6 +8,7 @@ import android.os.IBinder;
import android.util.Log;
import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
@@ -22,18 +23,27 @@ import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
public final class PlayerHolder {
+
private PlayerHolder() {
}
- private static final boolean DEBUG = MainActivity.DEBUG;
- private static final String TAG = "PlayerHolder";
+ private static PlayerHolder instance;
+ public static synchronized PlayerHolder getInstance() {
+ if (PlayerHolder.instance == null) {
+ PlayerHolder.instance = new PlayerHolder();
+ }
+ return PlayerHolder.instance;
+ }
- private static PlayerServiceExtendedEventListener listener;
+ private final boolean DEBUG = MainActivity.DEBUG;
+ private final String TAG = PlayerHolder.class.getSimpleName();
- private static ServiceConnection serviceConnection;
- public static boolean bound;
- private static MainPlayer playerService;
- private static Player player;
+ private PlayerServiceExtendedEventListener listener;
+
+ private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
+ public boolean bound;
+ private MainPlayer playerService;
+ private Player player;
/**
* Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service,
@@ -42,26 +52,31 @@ public final class PlayerHolder {
* @return Current PlayerType
*/
@Nullable
- public static MainPlayer.PlayerType getType() {
+ public MainPlayer.PlayerType getType() {
if (player == null) {
return null;
}
return player.getPlayerType();
}
- public static boolean isPlaying() {
+ public boolean isPlaying() {
if (player == null) {
return false;
}
return player.isPlaying();
}
- public static boolean isPlayerOpen() {
+ public boolean isPlayerOpen() {
return player != null;
}
- public static void setListener(final PlayerServiceExtendedEventListener newListener) {
+ public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
listener = newListener;
+
+ if (listener == null) {
+ return;
+ }
+
// Force reload data from service
if (player != null) {
listener.onServiceConnected(player, playerService, false);
@@ -69,14 +84,15 @@ public final class PlayerHolder {
}
}
- public static void removeListener() {
- listener = null;
+ // helper to handle context in common place as using the same
+ // context to bind/unbind a service is crucial
+ private Context getCommonContext() {
+ return App.getApp();
}
-
- public static void startService(final Context context,
- final boolean playAfterConnect,
- final PlayerServiceExtendedEventListener newListener) {
+ public void startService(final boolean playAfterConnect,
+ final PlayerServiceExtendedEventListener newListener) {
+ final Context context = getCommonContext();
setListener(newListener);
if (bound) {
return;
@@ -85,58 +101,65 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
- context.startService(new Intent(context, MainPlayer.class));
- serviceConnection = getServiceConnection(context, playAfterConnect);
+ ContextCompat.startForegroundService(context, new Intent(context, MainPlayer.class));
+ serviceConnection.doPlayAfterConnect(playAfterConnect);
bind(context);
}
- public static void stopService(final Context context) {
+ public void stopService() {
+ final Context context = getCommonContext();
unbind(context);
context.stopService(new Intent(context, MainPlayer.class));
}
- private static ServiceConnection getServiceConnection(final Context context,
- final boolean playAfterConnect) {
- return new ServiceConnection() {
- @Override
- public void onServiceDisconnected(final ComponentName compName) {
- if (DEBUG) {
- Log.d(TAG, "Player service is disconnected");
- }
+ class PlayerServiceConnection implements ServiceConnection {
- unbind(context);
+ private boolean playAfterConnect = false;
+
+ public void doPlayAfterConnect(final boolean playAfterConnection) {
+ this.playAfterConnect = playAfterConnection;
+ }
+
+ @Override
+ public void onServiceDisconnected(final ComponentName compName) {
+ if (DEBUG) {
+ Log.d(TAG, "Player service is disconnected");
}
- @Override
- public void onServiceConnected(final ComponentName compName, final IBinder service) {
- if (DEBUG) {
- Log.d(TAG, "Player service is connected");
- }
- final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service;
+ final Context context = getCommonContext();
+ unbind(context);
+ }
- playerService = localBinder.getService();
- player = localBinder.getPlayer();
- if (listener != null) {
- listener.onServiceConnected(player, playerService, playAfterConnect);
- }
- startPlayerListener();
+ @Override
+ public void onServiceConnected(final ComponentName compName, final IBinder service) {
+ if (DEBUG) {
+ Log.d(TAG, "Player service is connected");
}
- };
- }
+ final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service;
- private static void bind(final Context context) {
+ playerService = localBinder.getService();
+ player = localBinder.getPlayer();
+ if (listener != null) {
+ listener.onServiceConnected(player, playerService, playAfterConnect);
+ }
+ startPlayerListener();
+ }
+ };
+
+ private void bind(final Context context) {
if (DEBUG) {
Log.d(TAG, "bind() called");
}
final Intent serviceIntent = new Intent(context, MainPlayer.class);
- bound = context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE);
+ bound = context.bindService(serviceIntent, serviceConnection,
+ Context.BIND_AUTO_CREATE);
if (!bound) {
context.unbindService(serviceConnection);
}
}
- private static void unbind(final Context context) {
+ private void unbind(final Context context) {
if (DEBUG) {
Log.d(TAG, "unbind() called");
}
@@ -153,21 +176,19 @@ public final class PlayerHolder {
}
}
-
- private static void startPlayerListener() {
+ private void startPlayerListener() {
if (player != null) {
- player.setFragmentListener(INNER_LISTENER);
+ player.setFragmentListener(internalListener);
}
}
- private static void stopPlayerListener() {
+ private void stopPlayerListener() {
if (player != null) {
- player.removeFragmentListener(INNER_LISTENER);
+ player.removeFragmentListener(internalListener);
}
}
-
- private static final PlayerServiceEventListener INNER_LISTENER =
+ private final PlayerServiceEventListener internalListener =
new PlayerServiceEventListener() {
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
@@ -242,7 +263,7 @@ public final class PlayerHolder {
if (listener != null) {
listener.onServiceStopped();
}
- unbind(App.getApp());
+ unbind(getCommonContext());
}
};
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java
new file mode 100644
index 000000000..0814092fa
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java
@@ -0,0 +1,62 @@
+package org.schabi.newpipe.player.playback;
+
+import android.content.Context;
+import android.view.SurfaceHolder;
+
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.video.DummySurface;
+
+/**
+ * Prevent error message: 'Unrecoverable player error occurred'
+ * In case of rotation some users see this kind of an error which is preventable
+ * having a Callback that handles the lifecycle of the surface.
+ *
+ * How?: In case we are no longer able to write to the surface eg. through rotation/putting in
+ * background we set set a DummySurface. Although it it works on API >= 23 only.
+ * Result: we get a little video interruption (audio is still fine) but we won't get the
+ * 'Unrecoverable player error occurred' error message.
+ *
+ * This implementation is based on:
+ * 'ExoPlayer stuck in buffering after re-adding the surface view a few time #2703'
+ *
+ * -> exoplayer fix suggestion link
+ * https://github.com/google/ExoPlayer/issues/2703#issuecomment-300599981
+ */
+public final class SurfaceHolderCallback implements SurfaceHolder.Callback {
+
+ private final Context context;
+ private final SimpleExoPlayer player;
+ private DummySurface dummySurface;
+
+ public SurfaceHolderCallback(final Context context, final SimpleExoPlayer player) {
+ this.context = context;
+ this.player = player;
+ }
+
+ @Override
+ public void surfaceCreated(final SurfaceHolder holder) {
+ player.setVideoSurface(holder.getSurface());
+ }
+
+ @Override
+ public void surfaceChanged(final SurfaceHolder holder,
+ final int format,
+ final int width,
+ final int height) {
+ }
+
+ @Override
+ public void surfaceDestroyed(final SurfaceHolder holder) {
+ if (dummySurface == null) {
+ dummySurface = DummySurface.newInstanceV17(context, false);
+ }
+ player.setVideoSurface(dummySurface);
+ }
+
+ public void release() {
+ if (dummySurface != null) {
+ dummySurface.release();
+ dummySurface = null;
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
index 6131d8565..014c13339 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
@@ -40,29 +40,25 @@ import io.reactivex.rxjava3.subjects.BehaviorSubject;
*/
public abstract class PlayQueue implements Serializable {
public static final boolean DEBUG = MainActivity.DEBUG;
-
- private ArrayList backup;
- private ArrayList streams;
-
@NonNull
private final AtomicInteger queueIndex;
- private final ArrayList history;
+ private final List history = new ArrayList<>();
+
+ private List backup;
+ private List streams;
private transient BehaviorSubject eventBroadcast;
private transient Flowable broadcastReceiver;
-
- private transient boolean disposed;
+ private transient boolean disposed = false;
PlayQueue(final int index, final List startWith) {
- streams = new ArrayList<>();
- streams.addAll(startWith);
- history = new ArrayList<>();
+ streams = new ArrayList<>(startWith);
+
if (streams.size() > index) {
history.add(streams.get(index));
}
queueIndex = new AtomicInteger(index);
- disposed = false;
}
/*//////////////////////////////////////////////////////////////////////////
@@ -137,18 +133,36 @@ public abstract class PlayQueue implements Serializable {
public synchronized void setIndex(final int index) {
final int oldIndex = getIndex();
- int newIndex = index;
+ final int newIndex;
+
if (index < 0) {
newIndex = 0;
+ } else if (index < streams.size()) {
+ // Regular assignment for index in bounds
+ newIndex = index;
+ } else if (streams.isEmpty()) {
+ // Out of bounds from here on
+ // Need to check if stream is empty to prevent arithmetic error and negative index
+ newIndex = 0;
+ } else if (isComplete()) {
+ // Circular indexing
+ newIndex = index % streams.size();
+ } else {
+ // Index of last element
+ newIndex = streams.size() - 1;
}
- if (index >= streams.size()) {
- newIndex = isComplete() ? index % streams.size() : streams.size() - 1;
- }
+
+ queueIndex.set(newIndex);
+
if (oldIndex != newIndex) {
history.add(streams.get(newIndex));
}
- queueIndex.set(newIndex);
+ /*
+ TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
+ different from the old one but this is emitted regardless? Not sure what this what it does
+ exactly so I won't touch it
+ */
broadcast(new SelectEvent(oldIndex, newIndex));
}
@@ -180,8 +194,6 @@ public abstract class PlayQueue implements Serializable {
* @return the index of the given item
*/
public int indexOf(@NonNull final PlayQueueItem item) {
- // referential equality, can't think of a better way to do this
- // todo: better than this
return streams.indexOf(item);
}
@@ -410,34 +422,42 @@ public abstract class PlayQueue implements Serializable {
}
/**
- * Shuffles the current play queue.
+ * Shuffles the current play queue
*
- * This method first backs up the existing play queue and item being played.
- * Then a newly shuffled play queue will be generated along with currently
- * playing item placed at the beginning of the queue.
+ * This method first backs up the existing play queue and item being played. Then a newly
+ * shuffled play queue will be generated along with currently playing item placed at the
+ * beginning of the queue. This item will also be added to the history.
*
*
- * Will emit a {@link ReorderEvent} in any context.
+ * Will emit a {@link ReorderEvent} if shuffled.
*
+ *
+ * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
+ * top, so shuffling a size-2 list does nothing)
*/
public synchronized void shuffle() {
+ // Can't shuffle an list that's empty or only has one element
+ if (size() <= 2) {
+ return;
+ }
+ // Create a backup if it doesn't already exist
if (backup == null) {
backup = new ArrayList<>(streams);
}
- final int originIndex = getIndex();
- final PlayQueueItem current = getItem();
+
+ final int originalIndex = getIndex();
+ final PlayQueueItem currentItem = getItem();
+
Collections.shuffle(streams);
- final int newIndex = streams.indexOf(current);
- if (newIndex != -1) {
- streams.add(0, streams.remove(newIndex));
- }
+ // Move currentItem to the head of the queue
+ streams.remove(currentItem);
+ streams.add(0, currentItem);
queueIndex.set(0);
- if (streams.size() > 0) {
- history.add(streams.get(0));
- }
- broadcast(new ReorderEvent(originIndex, queueIndex.get()));
+ history.add(currentItem);
+
+ broadcast(new ReorderEvent(originalIndex, 0));
}
/**
@@ -457,7 +477,6 @@ public abstract class PlayQueue implements Serializable {
final int originIndex = getIndex();
final PlayQueueItem current = getItem();
- streams.clear();
streams = backup;
backup = null;
@@ -500,22 +519,19 @@ public abstract class PlayQueue implements Serializable {
* we don't have to do anything with new queue.
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
- * */
+ */
@Override
public boolean equals(@Nullable final Object obj) {
- if (!(obj instanceof PlayQueue)
- || getStreams().size() != ((PlayQueue) obj).getStreams().size()) {
+ if (!(obj instanceof PlayQueue)) {
return false;
}
-
final PlayQueue other = (PlayQueue) obj;
- for (int i = 0; i < getStreams().size(); i++) {
- if (!getItem(i).getUrl().equals(other.getItem(i).getUrl())) {
- return false;
- }
- }
+ return streams.equals(other.streams);
+ }
- return true;
+ @Override
+ public int hashCode() {
+ return streams.hashCode();
}
public boolean isDisposed() {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java
index 462b9eb53..dd95fb4d5 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java
@@ -182,8 +182,10 @@ public class PlayQueueAdapter extends RecyclerView.Adapter {
switch (type) {
case C.TYPE_SS:
return dataSource.getLiveSsMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
case C.TYPE_DASH:
return dataSource.getLiveDashMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
case C.TYPE_HLS:
return dataSource.getLiveHlsMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
default:
throw new IllegalStateException("Unsupported type: " + type);
}
@@ -68,16 +69,16 @@ public interface PlaybackResolver extends Resolver {
switch (type) {
case C.TYPE_SS:
return dataSource.getLiveSsMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
case C.TYPE_DASH:
return dataSource.getDashMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
case C.TYPE_HLS:
return dataSource.getHlsMediaSourceFactory().setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
case C.TYPE_OTHER:
return dataSource.getExtractorMediaSourceFactory(cacheKey).setTag(metadata)
- .createMediaSource(uri);
+ .createMediaSource(MediaItem.fromUri(uri));
default:
throw new IllegalStateException("Unsupported type: " + type);
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java
index a2b3a1d3d..245a85e71 100644
--- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java
+++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java
@@ -6,7 +6,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MergingMediaSource;
@@ -22,7 +22,6 @@ import org.schabi.newpipe.util.ListHelper;
import java.util.ArrayList;
import java.util.List;
-import static com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT;
import static com.google.android.exoplayer2.C.TIME_UNSET;
public class VideoPlaybackResolver implements PlaybackResolver {
@@ -101,12 +100,12 @@ public class VideoPlaybackResolver implements PlaybackResolver {
if (mimeType == null) {
continue;
}
-
- final Format textFormat = Format.createTextSampleFormat(null, mimeType,
- SELECTION_FLAG_AUTOSELECT,
- PlayerHelper.captionLanguageOf(context, subtitle));
final MediaSource textSource = dataSource.getSampleMediaSourceFactory()
- .createMediaSource(Uri.parse(subtitle.getUrl()), textFormat, TIME_UNSET);
+ .createMediaSource(
+ new MediaItem.Subtitle(Uri.parse(subtitle.getUrl()),
+ mimeType,
+ PlayerHelper.captionLanguageOf(context, subtitle)),
+ TIME_UNSET);
mediaSources.add(textSource);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java
new file mode 100644
index 000000000..54d11da83
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java
@@ -0,0 +1,108 @@
+package org.schabi.newpipe.player.seekbarpreview;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.preference.PreferenceManager;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.util.DeviceUtils;
+
+import java.lang.annotation.Retention;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.IntSupplier;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.HIGH_QUALITY;
+import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.LOW_QUALITY;
+import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.NONE;
+
+/**
+ * Helper for the seekbar preview.
+ */
+public final class SeekbarPreviewThumbnailHelper {
+
+ // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25)
+ // or it fails with an IllegalArgumentException
+ // https://stackoverflow.com/a/54744028
+ public static final String TAG = "SeekbarPrevThumbHelper";
+
+ private SeekbarPreviewThumbnailHelper() {
+ // No impl pls
+ }
+
+ @Retention(SOURCE)
+ @IntDef({HIGH_QUALITY, LOW_QUALITY,
+ NONE})
+ public @interface SeekbarPreviewThumbnailType {
+ int HIGH_QUALITY = 0;
+ int LOW_QUALITY = 1;
+ int NONE = 2;
+ }
+
+ ////////////////////////////////////////////////////////////////////////////
+ // Settings Resolution
+ ///////////////////////////////////////////////////////////////////////////
+
+ @SeekbarPreviewThumbnailType
+ public static int getSeekbarPreviewThumbnailType(@NonNull final Context context) {
+ final String type = PreferenceManager.getDefaultSharedPreferences(context).getString(
+ context.getString(R.string.seekbar_preview_thumbnail_key), "");
+ if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_none))) {
+ return NONE;
+ } else if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_low_quality))) {
+ return LOW_QUALITY;
+ } else {
+ return HIGH_QUALITY; // default
+ }
+ }
+
+ public static void tryResizeAndSetSeekbarPreviewThumbnail(
+ @NonNull final Context context,
+ @NonNull final Optional optPreviewThumbnail,
+ @NonNull final ImageView currentSeekbarPreviewThumbnail,
+ @NonNull final IntSupplier baseViewWidthSupplier) {
+
+ if (!optPreviewThumbnail.isPresent()) {
+ currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
+ return;
+ }
+
+ currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE);
+ final Bitmap srcBitmap = optPreviewThumbnail.get();
+
+ // Resize original bitmap
+ try {
+ Objects.requireNonNull(srcBitmap);
+
+ final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1;
+ final int newWidth = Math.max(
+ Math.min(
+ // Use 1/4 of the width for the preview
+ Math.round(baseViewWidthSupplier.getAsInt() / 4f),
+ // Scaling more than that factor looks really pixelated -> max
+ Math.round(srcWidth * 2.5f)
+ ),
+ // Min width = 10dp
+ DeviceUtils.dpToPx(10, context)
+ );
+
+ final float scaleFactor = (float) newWidth / srcWidth;
+ final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor);
+
+ currentSeekbarPreviewThumbnail.setImageBitmap(
+ Bitmap.createScaledBitmap(srcBitmap, newWidth, newHeight, true));
+ } catch (final Exception ex) {
+ Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex);
+ currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
+ } finally {
+ srcBitmap.recycle();
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
new file mode 100644
index 000000000..30c5ce910
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
@@ -0,0 +1,252 @@
+package org.schabi.newpipe.player.seekbarpreview;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.google.common.base.Stopwatch;
+import com.nostra13.universalimageloader.core.ImageLoader;
+
+import org.schabi.newpipe.extractor.stream.Frameset;
+import org.schabi.newpipe.util.ImageDisplayConstants;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType;
+
+public class SeekbarPreviewThumbnailHolder {
+
+ // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25)
+ // or it fails with an IllegalArgumentException
+ // https://stackoverflow.com/a/54744028
+ public static final String TAG = "SeekbarPrevThumbHolder";
+
+ // Key = Position of the picture in milliseconds
+ // Supplier = Supplies the bitmap for that position
+ private final Map> seekbarPreviewData = new ConcurrentHashMap<>();
+
+ // This ensures that if the reset is still undergoing
+ // and another reset starts, only the last reset is processed
+ private UUID currentUpdateRequestIdentifier = UUID.randomUUID();
+
+ public synchronized void resetFrom(
+ @NonNull final Context context,
+ final List