diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 544d51ca6..d24aaa699 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -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)); + + 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(); 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..f8e4e9ed7 --- /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 = 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(); + } + } +} 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 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 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 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> 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 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(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java new file mode 100644 index 000000000..46c278bf2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java @@ -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; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java index 37ebd636a..62e80275e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java +++ b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java @@ -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() { } }