diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
index db11d7c9a..1b597a711 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
@@ -2,6 +2,7 @@ package org.schabi.newpipe.streams;
import static org.schabi.newpipe.MainActivity.DEBUG;
+import android.graphics.Bitmap;
import android.util.Log;
import android.util.Pair;
@@ -15,12 +16,15 @@ import org.schabi.newpipe.streams.WebMReader.SimpleBlock;
import org.schabi.newpipe.streams.WebMReader.WebMTrack;
import org.schabi.newpipe.streams.io.SharpStream;
+import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;
@@ -63,9 +67,19 @@ public class OggFromWebMWriter implements Closeable {
private final int[] crc32Table = new int[256];
private final StreamInfo streamInfo;
+ private final Bitmap thumbnail;
- public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target,
- @Nullable final StreamInfo streamInfo) {
+ /**
+ * Constructor of OggFromWebMWriter.
+ * @param source
+ * @param target
+ * @param streamInfo the stream info
+ * @param thumbnail the thumbnail bitmap used as cover art
+ */
+ public OggFromWebMWriter(@NonNull final SharpStream source,
+ @NonNull final SharpStream target,
+ @Nullable final StreamInfo streamInfo,
+ @Nullable final Bitmap thumbnail) {
if (!source.canRead() || !source.canRewind()) {
throw new IllegalArgumentException("source stream must be readable and allows seeking");
}
@@ -76,6 +90,7 @@ public class OggFromWebMWriter implements Closeable {
this.source = source;
this.output = target;
this.streamInfo = streamInfo;
+ this.thumbnail = thumbnail;
this.streamId = (int) System.currentTimeMillis();
@@ -299,6 +314,9 @@ public class OggFromWebMWriter implements Closeable {
.getUploadDate()
.getLocalDateTime()
.format(DateTimeFormatter.ISO_DATE)));
+ if (thumbnail != null) {
+ metadata.add(makeOpusPictureTag(thumbnail));
+ }
}
if (DEBUG) {
@@ -309,7 +327,7 @@ public class OggFromWebMWriter implements Closeable {
return makeOpusTagsHeader(metadata);
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
return new byte[]{
- 0x03, // ¿¿¿???
+ 0x03, // ???
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
@@ -339,6 +357,56 @@ public class OggFromWebMWriter implements Closeable {
return buf.array();
}
+ /**
+ * Adds the {@code METADATA_BLOCK_PICTURE} tag to the Opus metadata,
+ * containing the provided bitmap as cover art.
+ *
+ *
+ * One could also use the COVERART tag instead, but it is not as widely supported
+ * as METADATA_BLOCK_PICTURE.
+ *
+ *
+ * @param bitmap The bitmap to use as cover art
+ * @return The key-value pair representing the tag
+ */
+ private static Pair makeOpusPictureTag(final Bitmap bitmap) {
+ // FLAC picture block format (big-endian):
+ // uint32 picture_type
+ // uint32 mime_length, mime_string
+ // uint32 desc_length, desc_string
+ // uint32 width
+ // uint32 height
+ // uint32 color_depth
+ // uint32 colors_indexed
+ // uint32 data_length, data_bytes
+
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
+
+ final byte[] imageData = baos.toByteArray();
+ final byte[] mimeBytes = "image/jpeg".getBytes(StandardCharsets.UTF_8);
+ final byte[] descBytes = new byte[0]; // optional description
+ // fixed ints + mime + desc
+ final int headerSize = 4 * 8 + mimeBytes.length + descBytes.length;
+ final ByteBuffer buf = ByteBuffer.allocate(headerSize + imageData.length);
+ buf.putInt(3); // picture type: 3 = Cover (front)
+ buf.putInt(mimeBytes.length);
+ buf.put(mimeBytes);
+ buf.putInt(descBytes.length);
+ // no description
+ if (descBytes.length > 0) {
+ buf.put(descBytes);
+ }
+ buf.putInt(bitmap.getWidth()); // width (unknown)
+ buf.putInt(bitmap.getHeight()); // height (unknown)
+ buf.putInt(0); // color depth
+ buf.putInt(0); // colors indexed
+ buf.putInt(imageData.length);
+ buf.put(imageData);
+ final String b64 = Base64.getEncoder().encodeToString(buf.array());
+ return Pair.create("METADATA_BLOCK_PICTURE", b64);
+ }
+
/**
* This returns a complete "OpusTags" header, created from the provided metadata tags.
*
@@ -447,7 +515,8 @@ public class OggFromWebMWriter implements Closeable {
private boolean addPacketSegment(final int size) {
if (size > 65025) {
- throw new UnsupportedOperationException("page size cannot be larger than 65025");
+ throw new UnsupportedOperationException(
+ String.format("page size is %s but cannot be larger than 65025", size));
}
int available = (segmentTable.length - segmentTableSize) * 255;
diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
index c7e94c7f8..912ab10da 100644
--- a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
@@ -186,7 +186,7 @@ object ImageStrategy {
fun dbUrlToImageList(url: String?): List {
return when (url) {
null -> listOf()
- else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN))
+ else -> listOf(Image(url, Image.HEIGHT_UNKNOWN, Image.WIDTH_UNKNOWN, ResolutionLevel.UNKNOWN))
}
}
}
diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java
index 54340ce5d..da963d330 100644
--- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java
+++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java
@@ -1,5 +1,7 @@
package us.shandian.giga.get;
+import android.content.Context;
+import android.graphics.Bitmap;
import android.os.Handler;
import android.system.ErrnoException;
import android.system.OsConstants;
@@ -8,6 +10,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import org.schabi.newpipe.App;
import org.schabi.newpipe.DownloaderImpl;
import java.io.File;
@@ -21,11 +24,18 @@ import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.channels.ClosedByInterruptException;
+import java.util.List;
import java.util.Objects;
import javax.net.ssl.SSLException;
+import org.schabi.newpipe.extractor.Image;
+import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.streams.io.StoredFileHelper;
+import org.schabi.newpipe.util.image.CoilHelper;
+import org.schabi.newpipe.util.image.ImageStrategy;
+import org.schabi.newpipe.util.image.PreferredImageQuality;
+
import us.shandian.giga.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.util.Utility;
@@ -58,6 +68,10 @@ public class DownloadMission extends Mission {
public static final int ERROR_HTTP_NO_CONTENT = 204;
static final int ERROR_HTTP_FORBIDDEN = 403;
+ private StreamInfo streamInfo;
+ protected volatile Bitmap thumbnail;
+ protected volatile boolean thumbnailFetched = false;
+
/**
* The urls of the file to download
*/
@@ -153,7 +167,8 @@ public class DownloadMission extends Mission {
public transient Thread[] threads = new Thread[0];
public transient Thread init = null;
- public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) {
+ public DownloadMission(String[] urls, StoredFileHelper storage, char kind,
+ Postprocessing psInstance, StreamInfo streamInfo, Context context) {
if (Objects.requireNonNull(urls).length < 1)
throw new IllegalArgumentException("urls array is empty");
this.urls = urls;
@@ -163,6 +178,7 @@ public class DownloadMission extends Mission {
this.maxRetry = 3;
this.storage = storage;
this.psAlgorithm = psInstance;
+ this.streamInfo = streamInfo;
if (DEBUG && psInstance == null && urls.length > 1) {
Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?");
@@ -698,6 +714,7 @@ public class DownloadMission extends Mission {
Exception exception = null;
try {
+ psAlgorithm.setThumbnail(thumbnail);
psAlgorithm.run(this);
} catch (Exception err) {
Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err);
@@ -829,6 +846,34 @@ public class DownloadMission extends Mission {
}
}
+ /**
+ * Loads the thumbnail / cover art from a list of thumbnails.
+ * The highest quality is selected.
+ *
+ * @param images the list of thumbnails
+ */
+ public void fetchThumbnail(@NonNull final List images) {
+ if (images.isEmpty()) {
+ thumbnailFetched = true;
+ return;
+ }
+
+ try {
+ // Some containers have a limited size for embedded images / metadata.
+ // To avoid problems, we download a medium quality image.
+ // Alternative approaches are to either downscale a high res image or
+ // to download the correct size depending on the chosen post-processing algorithm.
+ final String thumbnailUrl = ImageStrategy.choosePreferredImage(
+ images, PreferredImageQuality.MEDIUM);
+ // TODO: get context from somewhere else
+ thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl);
+ thumbnailFetched = true;
+ } catch (final Exception e) {
+ Log.w(TAG, "fetchThumbnail: failed to load thumbnail", e);
+ thumbnailFetched = true;
+ return;
+ }
+ }
static class HttpError extends Exception {
final int statusCode;
diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java
index badb5f7ed..f76fadb31 100644
--- a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java
+++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java
@@ -34,7 +34,8 @@ class OggFromWebmDemuxer extends Postprocessing {
@Override
int process(SharpStream out, @NonNull SharpStream... sources) throws IOException {
- OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo);
+ OggFromWebMWriter demuxer = new OggFromWebMWriter(
+ sources[0], out, streamInfo, thumbnail);
demuxer.parseSource();
demuxer.selectTrack(0);
demuxer.build();
diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
index 1c9143252..c6161f19e 100644
--- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
+++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
@@ -1,5 +1,6 @@
package us.shandian.giga.postprocessing;
+import android.graphics.Bitmap;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -80,6 +81,7 @@ public abstract class Postprocessing implements Serializable {
private String[] args;
protected StreamInfo streamInfo;
+ protected Bitmap thumbnail;
private transient DownloadMission mission;
@@ -107,6 +109,10 @@ public abstract class Postprocessing implements Serializable {
}
}
+ public void setThumbnail(Bitmap thumbnail) {
+ this.thumbnail = thumbnail;
+ }
+
public void run(DownloadMission target) throws IOException {
this.mission = target;
diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
index 76da18b2d..e4f4ee6f5 100755
--- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
+++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
@@ -40,6 +40,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.download.DownloadActivity;
+import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.helper.LockManager;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
@@ -408,7 +409,8 @@ public class DownloadManagerService extends Service {
else
ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo);
- final DownloadMission mission = new DownloadMission(urls, storage, kind, ps);
+ final DownloadMission mission = new DownloadMission(
+ urls, storage, kind, ps, streamInfo, getApplicationContext());
mission.threadCount = threads;
mission.source = streamInfo.getUrl();
mission.nearLength = nearLength;
@@ -417,7 +419,18 @@ public class DownloadManagerService extends Service {
if (ps != null)
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
- handleConnectivityState(true);// first check the actual network status
+ if (streamInfo != null) {
+ new Thread(() -> {
+ try {
+ mission.fetchThumbnail(streamInfo.getThumbnails());
+ } catch (Exception e) {
+ Log.w(TAG, "failed to fetch thumbnail for mission: "
+ + mission.storage.getName(), e);
+ }
+ }, "ThumbnailFetcher").start();
+ }
+
+ handleConnectivityState(true); // first check the actual network status
mManager.startMission(mission);
}