Added seekbarThumbnailPreview

This commit is contained in:
litetex 2021-06-09 20:35:29 +02:00
parent 253526e565
commit 384d964827
5 changed files with 523 additions and 3 deletions

View File

@ -27,6 +27,8 @@ import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.view.ContextThemeWrapper;
import android.view.GestureDetector;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
@ -123,6 +125,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
import org.schabi.newpipe.player.resolver.MediaSourceTag;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ImageDisplayConstants;
import org.schabi.newpipe.util.ListHelper;
@ -379,6 +383,8 @@ public final class Player implements
@NonNull private final SharedPreferences prefs;
@NonNull private final HistoryRecordManager recordManager;
@NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
new SeekbarPreviewThumbnailHolder();
/*//////////////////////////////////////////////////////////////////////////
@ -1676,12 +1682,67 @@ public final class Player implements
@Override // seekbar listener
public void onProgressChanged(final SeekBar seekBar, final int progress,
final boolean fromUser) {
if (DEBUG && fromUser) {
// Currently we don't need method execution when fromUser is false
if (!fromUser) {
return;
}
if (DEBUG) {
Log.d(TAG, "onProgressChanged() called with: "
+ "seekBar = [" + seekBar + "], progress = [" + progress + "]");
}
if (fromUser) {
binding.currentDisplaySeek.setText(getTimeString(progress));
// Seekbar Preview Thumbnail
SeekbarPreviewThumbnailHelper
.tryResizeAndSetSeekbarPreviewThumbnail(
getContext(),
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
binding.currentSeekbarPreviewThumbnail,
binding.subtitleView::getWidth);
adjustSeekbarPreviewContainer();
}
private void adjustSeekbarPreviewContainer() {
try {
// Should only be required when an error occurred before
// and the layout was positioned in the center
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY);
// Calculate the current left position of seekbar progress in px
// More info: https://stackoverflow.com/q/20493577
final int currentSeekbarLeft =
binding.playbackSeekBar.getLeft()
+ binding.playbackSeekBar.getPaddingLeft()
+ binding.playbackSeekBar.getThumb().getBounds().left;
// Calculate the (unchecked) left position of the container
final int uncheckedContainerLeft =
currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2);
// Fix the position so it's within the boundaries
final int checkedContainerLeft =
Math.max(
Math.min(
uncheckedContainerLeft,
// Max left
binding.playbackWindowRoot.getWidth()
- binding.seekbarPreviewContainer.getWidth()
),
0 // Min left
);
// See also: https://stackoverflow.com/a/23249734
final LinearLayout.LayoutParams params =
new LinearLayout.LayoutParams(
binding.seekbarPreviewContainer.getLayoutParams());
params.setMarginStart(checkedContainerLeft);
binding.seekbarPreviewContainer.setLayoutParams(params);
} catch (final Exception ex) {
Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex);
// Fallback - position in the middle
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER);
}
}
@ -1702,6 +1763,8 @@ public final class Player implements
showControls(0);
animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SCALE_AND_ALPHA);
animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SCALE_AND_ALPHA);
}
@Override // seekbar listener
@ -1717,6 +1780,7 @@ public final class Player implements
binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress()));
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA);
if (currentState == STATE_PAUSED_SEEK) {
changeState(STATE_BUFFERING);
@ -2866,6 +2930,10 @@ public final class Player implements
binding.titleTextView.setText(tag.getMetadata().getName());
binding.channelTextView.setText(tag.getMetadata().getUploaderName());
this.seekbarPreviewThumbnailHolder.resetFrom(
this.getContext(),
tag.getMetadata().getPreviewFrames());
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
notifyMetadataUpdateToListeners();

View File

@ -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<Bitmap> 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 = 80dp
DeviceUtils.dpToPx(80, 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();
}
}
}

View File

@ -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<Integer, Supplier<Bitmap>> 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<Frameset> framesets) {
final int seekbarPreviewType =
SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType(context);
final UUID updateRequestIdentifier = UUID.randomUUID();
this.currentUpdateRequestIdentifier = updateRequestIdentifier;
final ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
try {
resetFromAsync(seekbarPreviewType, framesets, updateRequestIdentifier);
} catch (final Exception ex) {
Log.e(TAG, "Failed to execute async", ex);
}
});
// ensure that the executorService stops/destroys it's threads
// after the task is finished
executorService.shutdown();
}
private void resetFromAsync(
final int seekbarPreviewType,
final List<Frameset> framesets,
final UUID updateRequestIdentifier) {
Log.d(TAG, "Clearing seekbarPreviewData");
seekbarPreviewData.clear();
if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) {
Log.d(TAG, "Not processing seekbarPreviewData due to settings");
return;
}
final Frameset frameset = getFrameSetForType(framesets, seekbarPreviewType);
if (frameset == null) {
Log.d(TAG, "No frameset was found to fill seekbarPreviewData");
return;
}
Log.d(TAG, "Frameset quality info: "
+ "[width=" + frameset.getFrameWidth()
+ ", heigh=" + frameset.getFrameHeight() + "]");
// Abort method execution if we are not the latest request
if (!isRequestIdentifierCurrent(updateRequestIdentifier)) {
return;
}
generateDataFrom(frameset, updateRequestIdentifier);
}
private Frameset getFrameSetForType(
final List<Frameset> framesets,
final int seekbarPreviewType) {
if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) {
Log.d(TAG, "Strategy for seekbarPreviewData: high quality");
return framesets.stream()
.max(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth()))
.orElse(null);
} else {
Log.d(TAG, "Strategy for seekbarPreviewData: low quality");
return framesets.stream()
.min(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth()))
.orElse(null);
}
}
private void generateDataFrom(
final Frameset frameset,
final UUID updateRequestIdentifier) {
Log.d(TAG, "Starting generation of seekbarPreviewData");
final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null;
int currentPosMs = 0;
int pos = 1;
final int frameCountPerUrl = frameset.getFramesPerPageX() * frameset.getFramesPerPageY();
// Process each url in the frameset
for (final String url : frameset.getUrls()) {
// get the bitmap
final Bitmap srcBitMap = getBitMapFrom(url);
// The data is not added directly to "seekbarPreviewData" due to
// concurrency and checks for "updateRequestIdentifier"
final Map<Integer, Supplier<Bitmap>> generatedDataForUrl = new HashMap<>();
// The bitmap consists of several images, which we process here
// foreach frame in the returned bitmap
for (int i = 0; i < frameCountPerUrl; i++) {
// Frames outside the video length are skipped
if (pos > frameset.getTotalCount()) {
break;
}
// Get the bounds where the frame is found
final int[] bounds = frameset.getFrameBoundsAt(currentPosMs);
generatedDataForUrl.put(currentPosMs, () -> {
// It can happen, that the original bitmap could not be downloaded
// In such a case - we don't want a NullPointer - simply return null
if (srcBitMap == null) {
return null;
}
// Cut out the corresponding bitmap form the "srcBitMap"
return Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2],
frameset.getFrameWidth(), frameset.getFrameHeight());
});
currentPosMs += frameset.getDurationPerFrame();
pos++;
}
// Check if we are still the latest request
// If not abort method execution
if (isRequestIdentifierCurrent(updateRequestIdentifier)) {
seekbarPreviewData.putAll(generatedDataForUrl);
} else {
Log.d(TAG, "Aborted of generation of seekbarPreviewData");
break;
}
}
if (sw != null) {
Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop().toString());
}
}
private Bitmap getBitMapFrom(final String url) {
if (url == null) {
Log.w(TAG, "url is null; This should never happen");
return null;
}
final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null;
try {
final SyncImageLoadingListener syncImageLoadingListener =
new SyncImageLoadingListener();
Log.d(TAG, "Downloading bitmap for seekbarPreview from '" + url + "'");
// Ensure that everything is running
ImageLoader.getInstance().resume();
// Load the image
// Impl-Note:
// Ensure that your are not running on the main-Thread this will otherwise hang
ImageLoader.getInstance().loadImage(
url,
ImageDisplayConstants.DISPLAY_SEEKBAR_PREVIEW_OPTIONS,
syncImageLoadingListener);
// Get the bitmap within the timeout
final Bitmap bitmap =
syncImageLoadingListener.waitForBitmapOrThrow(30, TimeUnit.SECONDS);
if (sw != null) {
Log.d(TAG,
"Download of bitmap for seekbarPreview from '" + url
+ "' took " + sw.stop().toString());
}
return bitmap;
} catch (final Exception ex) {
Log.w(TAG,
"Failed to get bitmap for seekbarPreview from url='" + url
+ "' in time",
ex);
return null;
}
}
private boolean isRequestIdentifierCurrent(final UUID requestIdentifier) {
return this.currentUpdateRequestIdentifier.equals(requestIdentifier);
}
public Optional<Bitmap> getBitmapAt(final int positionInMs) {
// Check if the BitmapData is empty
if (seekbarPreviewData.isEmpty()) {
return Optional.empty();
}
// Get the closest frame to the requested position
final int closestIndexPosition =
seekbarPreviewData.keySet().stream()
.min(Comparator.comparingInt(i -> Math.abs(i - positionInMs)))
.orElse(-1);
// this should never happen, because
// it indicates that "seekbarPreviewData" is empty which was already checked
if (closestIndexPosition == -1) {
return Optional.empty();
}
try {
// Get the bitmap for the position (executes the supplier)
return Optional.ofNullable(seekbarPreviewData.get(closestIndexPosition).get());
} catch (final Exception ex) {
// If there is an error, log it and return Optional.empty
Log.w(TAG, "Unable to get seekbar preview", ex);
return Optional.empty();
}
}
}

View File

@ -0,0 +1,87 @@
package org.schabi.newpipe.player.seekbarpreview;
import android.graphics.Bitmap;
import android.view.View;
import com.nostra13.universalimageloader.core.assist.FailReason;
import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Listener for synchronously downloading of an image/bitmap.
*/
public class SyncImageLoadingListener extends SimpleImageLoadingListener {
private final CountDownLatch countDownLatch = new CountDownLatch(1);
private Bitmap bitmap;
private boolean cancelled = false;
private FailReason failReason = null;
@SuppressWarnings("checkstyle:HiddenField")
@Override
public void onLoadingFailed(
final String imageUri,
final View view,
final FailReason failReason) {
this.failReason = failReason;
countDownLatch.countDown();
}
@Override
public void onLoadingComplete(
final String imageUri,
final View view,
final Bitmap loadedImage) {
bitmap = loadedImage;
countDownLatch.countDown();
}
@Override
public void onLoadingCancelled(final String imageUri, final View view) {
cancelled = true;
countDownLatch.countDown();
}
public Bitmap waitForBitmapOrThrow(final long timeout, final TimeUnit timeUnit)
throws InterruptedException, TimeoutException {
// Wait for the download to finish
if (!countDownLatch.await(timeout, timeUnit)) {
throw new TimeoutException("Couldn't get the image in time");
}
if (isCancelled()) {
throw new CancellationException("Download of image was cancelled");
}
if (getFailReason() != null) {
throw new RuntimeException("Failed to download image" + getFailReason().getType(),
getFailReason().getCause());
}
if (getBitmap() == null) {
throw new NullPointerException("Bitmap is null");
}
return getBitmap();
}
public Bitmap getBitmap() {
return bitmap;
}
public boolean isCancelled() {
return cancelled;
}
public FailReason getFailReason() {
return failReason;
}
}

View File

@ -56,5 +56,10 @@ public final class ImageDisplayConstants {
.showImageOnFail(R.drawable.dummy_thumbnail_playlist)
.build();
public static final DisplayImageOptions DISPLAY_SEEKBAR_PREVIEW_OPTIONS =
new DisplayImageOptions.Builder()
.cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS)
.build();
private ImageDisplayConstants() { }
}