diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java index f6554e71a8..81effe14b6 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java @@ -27,8 +27,10 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.StreamingDrmSessionManager; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; import com.google.android.exoplayer2.metadata.id3.GeobFrame; import com.google.android.exoplayer2.metadata.id3.Id3Frame; import com.google.android.exoplayer2.metadata.id3.PrivFrame; @@ -46,7 +48,6 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.io.IOException; import java.text.NumberFormat; -import java.util.List; import java.util.Locale; /** @@ -55,7 +56,7 @@ import java.util.Locale; /* package */ final class EventLogger implements ExoPlayer.EventListener, AudioRendererEventListener, VideoRendererEventListener, AdaptiveMediaSourceEventListener, ExtractorMediaSource.EventListener, StreamingDrmSessionManager.EventListener, - MetadataRenderer.Output> { + MetadataRenderer.Output { private static final String TAG = "EventLogger"; private static final int MAX_TIMELINE_ITEM_LINES = 3; @@ -157,6 +158,18 @@ import java.util.Locale; } Log.d(TAG, " ]"); } + // Log metadata for at most one of the tracks selected for the renderer. + if (trackSelection != null) { + for (int selectionIndex = 0; selectionIndex < trackSelection.length(); selectionIndex++) { + Metadata metadata = trackSelection.getFormat(selectionIndex).metadata; + if (metadata != null) { + Log.d(TAG, " Metadata ["); + printMetadata(metadata, " "); + Log.d(TAG, " ]"); + break; + } + } + } Log.d(TAG, " ]"); } } @@ -182,34 +195,13 @@ import java.util.Locale; Log.d(TAG, "]"); } - // MetadataRenderer.Output> + // MetadataRenderer.Output @Override - public void onMetadata(List id3Frames) { - for (Id3Frame id3Frame : id3Frames) { - if (id3Frame instanceof TxxxFrame) { - TxxxFrame txxxFrame = (TxxxFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: description=%s, value=%s", txxxFrame.id, - txxxFrame.description, txxxFrame.value)); - } else if (id3Frame instanceof PrivFrame) { - PrivFrame privFrame = (PrivFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: owner=%s", privFrame.id, privFrame.owner)); - } else if (id3Frame instanceof GeobFrame) { - GeobFrame geobFrame = (GeobFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, filename=%s, description=%s", - geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); - } else if (id3Frame instanceof ApicFrame) { - ApicFrame apicFrame = (ApicFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, description=%s", - apicFrame.id, apicFrame.mimeType, apicFrame.description)); - } else if (id3Frame instanceof TextInformationFrame) { - TextInformationFrame textInformationFrame = (TextInformationFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: description=%s", textInformationFrame.id, - textInformationFrame.description)); - } else { - Log.i(TAG, String.format("ID3 TimedMetadata %s", id3Frame.id)); - } - } + public void onMetadata(Metadata metadata) { + Log.d(TAG, "onMetadata ["); + printMetadata(metadata, " "); + Log.d(TAG, "]"); } // AudioRendererEventListener @@ -354,6 +346,39 @@ import java.util.Locale; Log.e(TAG, "internalError [" + getSessionTimeString() + ", " + type + "]", e); } + private void printMetadata(Metadata metadata, String prefix) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TxxxFrame) { + TxxxFrame txxxFrame = (TxxxFrame) entry; + Log.d(TAG, prefix + String.format("%s: description=%s, value=%s", txxxFrame.id, + txxxFrame.description, txxxFrame.value)); + } else if (entry instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) entry; + Log.d(TAG, prefix + String.format("%s: owner=%s", privFrame.id, privFrame.owner)); + } else if (entry instanceof GeobFrame) { + GeobFrame geobFrame = (GeobFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, filename=%s, description=%s", + geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); + } else if (entry instanceof ApicFrame) { + ApicFrame apicFrame = (ApicFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, description=%s", + apicFrame.id, apicFrame.mimeType, apicFrame.description)); + } else if (entry instanceof TextInformationFrame) { + TextInformationFrame textInformationFrame = (TextInformationFrame) entry; + Log.d(TAG, prefix + String.format("%s: description=%s", textInformationFrame.id, + textInformationFrame.description)); + } else if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + Log.d(TAG, prefix + String.format("%s: language=%s description=%s", commentFrame.id, + commentFrame.language, commentFrame.description, commentFrame.text)); + } else if (entry instanceof Id3Frame) { + Id3Frame id3Frame = (Id3Frame) entry; + Log.d(TAG, prefix + String.format("%s", id3Frame.id)); + } + } + } + private String getSessionTimeString() { return getTimeString(SystemClock.elapsedRealtime() - startTimeMs); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java index 9bdf330b02..1eb38de40f 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java @@ -24,6 +24,8 @@ import android.annotation.TargetApi; import android.media.MediaFormat; import android.os.Parcel; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; @@ -56,11 +58,14 @@ public final class FormatTest extends TestCase { TestUtil.buildTestData(128, 1 /* data seed */)); DrmInitData drmInitData = new DrmInitData(DRM_DATA_1, DRM_DATA_2); byte[] projectionData = new byte[] {1, 2, 3}; + Metadata metadata = new Metadata( + new TextInformationFrame("id1", "description1"), + new TextInformationFrame("id2", "description2")); Format formatToParcel = new Format("id", MimeTypes.VIDEO_MP4, MimeTypes.VIDEO_H264, null, 1024, 2048, 1920, 1080, 24, 90, 2, projectionData, C.STEREO_MODE_TOP_BOTTOM, 6, 44100, C.ENCODING_PCM_24BIT, 1001, 1002, 0, "und", Format.OFFSET_SAMPLE_RELATIVE, INIT_DATA, - drmInitData); + drmInitData, metadata); Parcel parcel = Parcel.obtain(); formatToParcel.writeToParcel(parcel, 0); diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java index f9ec1ee92b..6bfa6fccfc 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java @@ -16,8 +16,8 @@ package com.google.android.exoplayer2.metadata.id3; import android.test.MoreAsserts; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoderException; -import java.util.List; import junit.framework.TestCase; /** @@ -30,9 +30,9 @@ public class Id3DecoderTest extends TestCase { 3, 0, 109, 100, 105, 97, 108, 111, 103, 95, 86, 73, 78, 68, 73, 67, 79, 49, 53, 50, 55, 54, 54, 52, 95, 115, 116, 97, 114, 116, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - TxxxFrame txxxFrame = (TxxxFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + TxxxFrame txxxFrame = (TxxxFrame) metadata.get(0); assertEquals("", txxxFrame.description); assertEquals("mdialog_VINDICO1527664_start", txxxFrame.value); } @@ -42,9 +42,9 @@ public class Id3DecoderTest extends TestCase { 3, 105, 109, 97, 103, 101, 47, 106, 112, 101, 103, 0, 16, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - ApicFrame apicFrame = (ApicFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + ApicFrame apicFrame = (ApicFrame) metadata.get(0); assertEquals("image/jpeg", apicFrame.mimeType); assertEquals(16, apicFrame.pictureType); assertEquals("Hello World", apicFrame.description); @@ -56,9 +56,9 @@ public class Id3DecoderTest extends TestCase { byte[] rawId3 = new byte[] {73, 68, 51, 4, 0, 0, 0, 0, 0, 23, 84, 73, 84, 50, 0, 0, 0, 13, 0, 0, 3, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - TextInformationFrame textInformationFrame = (TextInformationFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertEquals("TIT2", textInformationFrame.id); assertEquals("Hello World", textInformationFrame.description); } diff --git a/library/src/main/java/com/google/android/exoplayer2/Format.java b/library/src/main/java/com/google/android/exoplayer2/Format.java index 550e6ab1d8..9528536296 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/src/main/java/com/google/android/exoplayer2/Format.java @@ -21,6 +21,7 @@ import android.media.MediaFormat; import android.os.Parcel; import android.os.Parcelable; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; @@ -57,6 +58,10 @@ public final class Format implements Parcelable { * Codecs of the format as described in RFC 6381, or null if unknown or not applicable. */ public final String codecs; + /** + * Metadata, or null if unknown or not applicable. + */ + public final Metadata metadata; // Container specific. @@ -185,7 +190,7 @@ public final class Format implements Parcelable { float frameRate, List initializationData) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, width, height, frameRate, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, initializationData, null); + NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, initializationData, null, null); } public static Format createVideoSampleFormat(String id, String sampleMimeType, String codecs, @@ -211,7 +216,7 @@ public final class Format implements Parcelable { return new Format(id, null, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, initializationData, - drmInitData); + drmInitData, null); } // Audio. @@ -222,7 +227,7 @@ public final class Format implements Parcelable { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, channelCount, sampleRate, NO_VALUE, NO_VALUE, NO_VALUE, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, initializationData, - null); + null, null); } public static Format createAudioSampleFormat(String id, String sampleMimeType, String codecs, @@ -239,18 +244,18 @@ public final class Format implements Parcelable { @C.SelectionFlags int selectionFlags, String language) { return createAudioSampleFormat(id, sampleMimeType, codecs, bitrate, maxInputSize, channelCount, sampleRate, pcmEncoding, NO_VALUE, NO_VALUE, initializationData, drmInitData, - selectionFlags, language); + selectionFlags, language, null); } public static Format createAudioSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, int maxInputSize, int channelCount, int sampleRate, @C.PcmEncoding int pcmEncoding, int encoderDelay, int encoderPadding, List initializationData, DrmInitData drmInitData, - @C.SelectionFlags int selectionFlags, String language) { + @C.SelectionFlags int selectionFlags, String language, Metadata metadata) { return new Format(id, null, sampleMimeType, codecs, bitrate, maxInputSize, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, - initializationData, drmInitData); + initializationData, drmInitData, metadata); } // Text. @@ -260,7 +265,7 @@ public final class Format implements Parcelable { String language) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, null, null); + NO_VALUE, NO_VALUE, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, null, null, null); } public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, @@ -274,7 +279,7 @@ public final class Format implements Parcelable { long subsampleOffsetUs) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, selectionFlags, language, subsampleOffsetUs, null, drmInitData); + NO_VALUE, selectionFlags, language, subsampleOffsetUs, null, drmInitData, null); } // Image. @@ -283,7 +288,7 @@ public final class Format implements Parcelable { int bitrate, List initializationData, String language, DrmInitData drmInitData) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, 0, language, OFFSET_SAMPLE_RELATIVE, initializationData, drmInitData); + NO_VALUE, 0, language, OFFSET_SAMPLE_RELATIVE, initializationData, drmInitData, null); } // Generic. @@ -292,14 +297,14 @@ public final class Format implements Parcelable { String sampleMimeType, int bitrate) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, null); + NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, null, null); } public static Format createSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, DrmInitData drmInitData) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, drmInitData); + NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, drmInitData, null); } /* package */ Format(String id, String containerMimeType, String sampleMimeType, String codecs, @@ -307,7 +312,8 @@ public final class Format implements Parcelable { float pixelWidthHeightRatio, byte[] projectionData, @C.StereoMode int stereoMode, int channelCount, int sampleRate, @C.PcmEncoding int pcmEncoding, int encoderDelay, int encoderPadding, @C.SelectionFlags int selectionFlags, String language, - long subsampleOffsetUs, List initializationData, DrmInitData drmInitData) { + long subsampleOffsetUs, List initializationData, DrmInitData drmInitData, + Metadata metadata) { this.id = id; this.containerMimeType = containerMimeType; this.sampleMimeType = sampleMimeType; @@ -332,6 +338,7 @@ public final class Format implements Parcelable { this.initializationData = initializationData == null ? Collections.emptyList() : initializationData; this.drmInitData = drmInitData; + this.metadata = metadata; } @SuppressWarnings("ResourceType") @@ -364,20 +371,21 @@ public final class Format implements Parcelable { initializationData.add(in.createByteArray()); } drmInitData = in.readParcelable(DrmInitData.class.getClassLoader()); + metadata = in.readParcelable(Metadata.class.getClassLoader()); } public Format copyWithMaxInputSize(int maxInputSize) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); } public Format copyWithSubsampleOffsetUs(long subsampleOffsetUs) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); } public Format copyWithContainerInfo(String id, String codecs, int bitrate, int width, int height, @@ -385,7 +393,7 @@ public final class Format implements Parcelable { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); } public Format copyWithManifestFormatInfo(Format manifestFormat, @@ -401,21 +409,28 @@ public final class Format implements Parcelable { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, selectionFlags, - language, subsampleOffsetUs, initializationData, drmInitData); + language, subsampleOffsetUs, initializationData, drmInitData, metadata); } public Format copyWithGaplessInfo(int encoderDelay, int encoderPadding) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); } public Format copyWithDrmInitData(DrmInitData drmInitData) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); + } + + public Format copyWithMetadata(Metadata metadata) { + return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, + width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, + stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, + selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData, metadata); } /** @@ -475,6 +490,7 @@ public final class Format implements Parcelable { result = 31 * result + sampleRate; result = 31 * result + (language == null ? 0 : language.hashCode()); result = 31 * result + (drmInitData == null ? 0 : drmInitData.hashCode()); + result = 31 * result + (metadata == null ? 0 : metadata.hashCode()); hashCode = result; } return hashCode; @@ -502,6 +518,7 @@ public final class Format implements Parcelable { || !Util.areEqual(sampleMimeType, other.sampleMimeType) || !Util.areEqual(codecs, other.codecs) || !Util.areEqual(drmInitData, other.drmInitData) + || !Util.areEqual(metadata, other.metadata) || !Arrays.equals(projectionData, other.projectionData) || initializationData.size() != other.initializationData.size()) { return false; @@ -574,6 +591,7 @@ public final class Format implements Parcelable { dest.writeByteArray(initializationData.get(i)); } dest.writeParcelable(drmInitData, 0); + dest.writeParcelable(metadata, 0); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d9c405cad6..ad12002b17 100644 --- a/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -35,9 +35,9 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; -import com.google.android.exoplayer2.metadata.id3.Id3Frame; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; @@ -107,7 +107,7 @@ public final class SimpleExoPlayer implements ExoPlayer { private SurfaceHolder surfaceHolder; private TextureView textureView; private TextRenderer.Output textOutput; - private MetadataRenderer.Output> id3Output; + private MetadataRenderer.Output metadataOutput; private VideoListener videoListener; private AudioRendererEventListener audioDebugListener; private VideoRendererEventListener videoDebugListener; @@ -364,12 +364,21 @@ public final class SimpleExoPlayer implements ExoPlayer { } /** - * Sets a listener to receive ID3 metadata events. + * @deprecated Use {@link #setMetadataOutput(MetadataRenderer.Output)} instead. + * @param output The output. + */ + @Deprecated + public void setId3Output(MetadataRenderer.Output output) { + setMetadataOutput(output); + } + + /** + * Sets a listener to receive metadata events. * * @param output The output. */ - public void setId3Output(MetadataRenderer.Output> output) { - id3Output = output; + public void setMetadataOutput(MetadataRenderer.Output output) { + metadataOutput = output; } // ExoPlayer implementation @@ -540,9 +549,9 @@ public final class SimpleExoPlayer implements ExoPlayer { Renderer textRenderer = new TextRenderer(componentListener, mainHandler.getLooper()); renderersList.add(textRenderer); - MetadataRenderer> id3Renderer = new MetadataRenderer<>(componentListener, + MetadataRenderer metadataRenderer = new MetadataRenderer(componentListener, mainHandler.getLooper(), new Id3Decoder()); - renderersList.add(id3Renderer); + renderersList.add(metadataRenderer); } private void buildExtensionRenderers(ArrayList renderersList, @@ -644,7 +653,7 @@ public final class SimpleExoPlayer implements ExoPlayer { } private final class ComponentListener implements VideoRendererEventListener, - AudioRendererEventListener, TextRenderer.Output, MetadataRenderer.Output>, + AudioRendererEventListener, TextRenderer.Output, MetadataRenderer.Output, SurfaceHolder.Callback, TextureView.SurfaceTextureListener { // VideoRendererEventListener implementation @@ -775,12 +784,12 @@ public final class SimpleExoPlayer implements ExoPlayer { } } - // MetadataRenderer.Output> implementation + // MetadataRenderer.Output implementation @Override - public void onMetadata(List id3Frames) { - if (id3Output != null) { - id3Output.onMetadata(id3Frames); + public void onMetadata(Metadata metadata) { + if (metadataOutput != null) { + metadataOutput.onMetadata(metadata); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java index 6eb9bc50de..7e2a1b4a23 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.extractor; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -65,6 +67,25 @@ public final class GaplessInfoHolder { return false; } + /** + * Populates the holder with data parsed from ID3 {@link Metadata}. + * + * @param metadata The metadata from which to parse the gapless information. + * @return Whether the holder was populated. + */ + public boolean setFromMetadata(Metadata metadata) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + if (setFromComment(commentFrame.description, commentFrame.text)) { + return true; + } + } + } + return false; + } + /** * Populates the holder with data parsed from a gapless playback comment (stored in an ID3 header * or MPEG 4 user data), if valid and non-zero. @@ -73,7 +94,7 @@ public final class GaplessInfoHolder { * @param data The comment's payload data. * @return Whether the holder was populated. */ - public boolean setFromComment(String name, String data) { + private boolean setFromComment(String name, String data) { if (!GAPLESS_COMMENT_ID.equals(name)) { return false; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java deleted file mode 100644 index 53f18df844..0000000000 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.extractor.mp3; - -import android.util.Pair; -import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.GaplessInfoHolder; -import com.google.android.exoplayer2.util.ParsableByteArray; -import com.google.android.exoplayer2.util.Util; -import java.io.IOException; -import java.nio.charset.Charset; - -/** - * Utility for parsing ID3 version 2 metadata in MP3 files. - */ -/* package */ final class Id3Util { - - /** - * The maximum valid length for metadata in bytes. - */ - private static final int MAXIMUM_METADATA_SIZE = 3 * 1024 * 1024; - - private static final int ID3_TAG = Util.getIntegerCodeForString("ID3"); - private static final Charset[] CHARSET_BY_ENCODING = new Charset[] {Charset.forName("ISO-8859-1"), - Charset.forName("UTF-16LE"), Charset.forName("UTF-16BE"), Charset.forName("UTF-8")}; - - /** - * Peeks data from the input and parses ID3 metadata. - * - * @param input The {@link ExtractorInput} from which data should be peeked. - * @param out The {@link GaplessInfoHolder} to populate. - * @throws IOException If an error occurred peeking from the input. - * @throws InterruptedException If the thread was interrupted. - */ - public static void parseId3(ExtractorInput input, GaplessInfoHolder out) - throws IOException, InterruptedException { - ParsableByteArray scratch = new ParsableByteArray(10); - int peekedId3Bytes = 0; - while (true) { - input.peekFully(scratch.data, 0, 10); - scratch.setPosition(0); - if (scratch.readUnsignedInt24() != ID3_TAG) { - break; - } - - int majorVersion = scratch.readUnsignedByte(); - int minorVersion = scratch.readUnsignedByte(); - int flags = scratch.readUnsignedByte(); - int length = scratch.readSynchSafeInt(); - if (!out.hasGaplessInfo() && canParseMetadata(majorVersion, minorVersion, flags, length)) { - byte[] frame = new byte[length]; - input.peekFully(frame, 0, length); - parseGaplessInfo(new ParsableByteArray(frame), majorVersion, flags, out); - } else { - input.advancePeekPosition(length); - } - - peekedId3Bytes += 10 + length; - } - input.resetPeekPosition(); - input.advancePeekPosition(peekedId3Bytes); - } - - private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags, - int length) { - return minorVersion != 0xFF && majorVersion >= 2 && majorVersion <= 4 - && length <= MAXIMUM_METADATA_SIZE - && !(majorVersion == 2 && ((flags & 0x3F) != 0 || (flags & 0x40) != 0)) - && !(majorVersion == 3 && (flags & 0x1F) != 0) - && !(majorVersion == 4 && (flags & 0x0F) != 0); - } - - private static void parseGaplessInfo(ParsableByteArray frame, int version, int flags, - GaplessInfoHolder out) { - unescape(frame, version, flags); - - // Skip any extended header. - frame.setPosition(0); - if (version == 3 && (flags & 0x40) != 0) { - if (frame.bytesLeft() < 4) { - return; - } - int extendedHeaderSize = frame.readUnsignedIntToInt(); - if (extendedHeaderSize > frame.bytesLeft()) { - return; - } - int paddingSize; - if (extendedHeaderSize >= 6) { - frame.skipBytes(2); // extended flags - paddingSize = frame.readUnsignedIntToInt(); - frame.setPosition(4); - frame.setLimit(frame.limit() - paddingSize); - if (frame.bytesLeft() < extendedHeaderSize) { - return; - } - } - frame.skipBytes(extendedHeaderSize); - } else if (version == 4 && (flags & 0x40) != 0) { - if (frame.bytesLeft() < 4) { - return; - } - int extendedHeaderSize = frame.readSynchSafeInt(); - if (extendedHeaderSize < 6 || extendedHeaderSize > frame.bytesLeft() + 4) { - return; - } - frame.setPosition(extendedHeaderSize); - } - - // Extract gapless playback metadata stored in comments. - Pair comment; - while ((comment = findNextComment(version, frame)) != null) { - if (comment.first.length() > 3) { - if (out.setFromComment(comment.first.substring(3), comment.second)) { - break; - } - } - } - } - - private static Pair findNextComment(int majorVersion, ParsableByteArray data) { - int frameSize; - while (true) { - if (majorVersion == 2) { - if (data.bytesLeft() < 6) { - return null; - } - String id = data.readString(3, Charset.forName("US-ASCII")); - if (id.equals("\0\0\0")) { - return null; - } - frameSize = data.readUnsignedInt24(); - if (frameSize == 0 || frameSize > data.bytesLeft()) { - return null; - } - if (id.equals("COM")) { - break; - } - } else /* major == 3 || major == 4 */ { - if (data.bytesLeft() < 10) { - return null; - } - String id = data.readString(4, Charset.forName("US-ASCII")); - if (id.equals("\0\0\0\0")) { - return null; - } - frameSize = majorVersion == 4 ? data.readSynchSafeInt() : data.readUnsignedIntToInt(); - if (frameSize == 0 || frameSize > data.bytesLeft() - 2) { - return null; - } - int flags = data.readUnsignedShort(); - boolean compressedOrEncrypted = (majorVersion == 4 && (flags & 0x0C) != 0) - || (majorVersion == 3 && (flags & 0xC0) != 0); - if (!compressedOrEncrypted && id.equals("COMM")) { - break; - } - } - data.skipBytes(frameSize); - } - - // The comment tag is at the reading position in data. - int encoding = data.readUnsignedByte(); - if (encoding < 0 || encoding >= CHARSET_BY_ENCODING.length) { - return null; - } - Charset charset = CHARSET_BY_ENCODING[encoding]; - String[] commentFields = data.readString(frameSize - 1, charset).split("\0"); - return commentFields.length == 2 ? Pair.create(commentFields[0], commentFields[1]) : null; - } - - private static boolean unescape(ParsableByteArray frame, int version, int flags) { - if (version != 4) { - if ((flags & 0x80) != 0) { - // Remove unsynchronization on ID3 version < 2.4.0. - byte[] bytes = frame.data; - int newLength = bytes.length; - for (int i = 0; i + 1 < newLength; i++) { - if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { - System.arraycopy(bytes, i + 2, bytes, i + 1, newLength - i - 2); - newLength--; - } - } - frame.setLimit(newLength); - } - } else { - // Remove unsynchronization on ID3 version 2.4.0. - if (canUnescapeVersion4(frame, false)) { - unescapeVersion4(frame, false); - } else if (canUnescapeVersion4(frame, true)) { - unescapeVersion4(frame, true); - } else { - return false; - } - } - return true; - } - - private static boolean canUnescapeVersion4(ParsableByteArray frame, - boolean unsignedIntDataSizeHack) { - frame.setPosition(0); - while (frame.bytesLeft() >= 10) { - if (frame.readInt() == 0) { - return true; - } - long dataSize = frame.readUnsignedInt(); - if (!unsignedIntDataSizeHack) { - // Parse the data size as a syncsafe integer. - if ((dataSize & 0x808080L) != 0) { - return false; - } - dataSize = (dataSize & 0x7F) | (((dataSize >> 8) & 0x7F) << 7) - | (((dataSize >> 16) & 0x7F) << 14) | (((dataSize >> 24) & 0x7F) << 21); - } - if (dataSize > frame.bytesLeft() - 2) { - return false; - } - int flags = frame.readUnsignedShort(); - if ((flags & 1) != 0) { - if (frame.bytesLeft() < 4) { - return false; - } - } - frame.skipBytes((int) dataSize); - } - return true; - } - - private static void unescapeVersion4(ParsableByteArray frame, boolean unsignedIntDataSizeHack) { - frame.setPosition(0); - byte[] bytes = frame.data; - while (frame.bytesLeft() >= 10) { - if (frame.readInt() == 0) { - return; - } - int dataSize = - unsignedIntDataSizeHack ? frame.readUnsignedIntToInt() : frame.readSynchSafeInt(); - int flags = frame.readUnsignedShort(); - int previousFlags = flags; - if ((flags & 1) != 0) { - // Strip data length indicator. - int offset = frame.getPosition(); - System.arraycopy(bytes, offset + 4, bytes, offset, frame.bytesLeft() - 4); - dataSize -= 4; - flags &= ~1; - frame.setLimit(frame.limit() - 4); - } - if ((flags & 2) != 0) { - // Unescape 0xFF00 to 0xFF in the next dataSize bytes. - int readOffset = frame.getPosition() + 1; - int writeOffset = readOffset; - for (int i = 0; i + 1 < dataSize; i++) { - if ((bytes[readOffset - 1] & 0xFF) == 0xFF && bytes[readOffset] == 0) { - readOffset++; - dataSize--; - } - bytes[writeOffset++] = bytes[readOffset++]; - } - frame.setLimit(frame.limit() - (readOffset - writeOffset)); - System.arraycopy(bytes, readOffset, bytes, writeOffset, frame.bytesLeft() - readOffset); - flags &= ~2; - } - if (flags != previousFlags || unsignedIntDataSizeHack) { - int dataSizeOffset = frame.getPosition() - 6; - writeSyncSafeInteger(bytes, dataSizeOffset, dataSize); - bytes[dataSizeOffset + 4] = (byte) (flags >> 8); - bytes[dataSizeOffset + 5] = (byte) (flags & 0xFF); - } - frame.skipBytes(dataSize); - } - } - - private static void writeSyncSafeInteger(byte[] bytes, int offset, int value) { - bytes[offset] = (byte) ((value >> 21) & 0x7F); - bytes[offset + 1] = (byte) ((value >> 14) & 0x7F); - bytes[offset + 2] = (byte) ((value >> 7) & 0x7F); - bytes[offset + 3] = (byte) (value & 0x7F); - } - - private Id3Util() {} - -} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index ab501af1cb..02b92f2077 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -27,6 +27,8 @@ import com.google.android.exoplayer2.extractor.MpegAudioHeader; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; @@ -57,6 +59,10 @@ public final class Mp3Extractor implements Extractor { * The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up. */ private static final int MAX_SNIFF_BYTES = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; + /** + * Maximum length of data read into {@link #scratch}. + */ + private static final int SCRATCH_LENGTH = 10; /** * Mask that includes the audio header values that must match between frames. @@ -77,6 +83,7 @@ public final class Mp3Extractor implements Extractor { private int synchronizedHeaderData; + private Metadata metadata; private Seeker seeker; private long basisTimeUs; private long samplesRead; @@ -97,7 +104,7 @@ public final class Mp3Extractor implements Extractor { */ public Mp3Extractor(long forcedFirstSampleTimestampUs) { this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs; - scratch = new ParsableByteArray(4); + scratch = new ParsableByteArray(SCRATCH_LENGTH); synchronizedHeader = new MpegAudioHeader(); gaplessInfoHolder = new GaplessInfoHolder(); basisTimeUs = C.TIME_UNSET; @@ -144,7 +151,7 @@ public final class Mp3Extractor implements Extractor { trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType, null, Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels, synchronizedHeader.sampleRate, Format.NO_VALUE, gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding, null, null, 0, null)); + gaplessInfoHolder.encoderPadding, null, null, 0, null, metadata)); } return readSample(input); } @@ -199,7 +206,7 @@ public final class Mp3Extractor implements Extractor { int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; input.resetPeekPosition(); if (input.getPosition() == 0) { - Id3Util.parseId3(input, gaplessInfoHolder); + peekId3Data(input); peekedId3Bytes = (int) input.getPeekPosition(); if (!sniffing) { input.skipFully(peekedId3Bytes); @@ -253,6 +260,45 @@ public final class Mp3Extractor implements Extractor { return true; } + /** + * Peeks ID3 data from the input, including gapless playback information. + * + * @param input The {@link ExtractorInput} from which data should be peeked. + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + private void peekId3Data(ExtractorInput input) throws IOException, InterruptedException { + int peekedId3Bytes = 0; + while (true) { + input.peekFully(scratch.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != Id3Decoder.ID3_TAG) { + // Not an ID3 tag. + break; + } + scratch.skipBytes(3); // Skip major version, minor version and flags. + int framesLength = scratch.readSynchSafeInt(); + int tagLength = Id3Decoder.ID3_HEADER_LENGTH + framesLength; + + if (metadata == null) { + byte[] id3Data = new byte[tagLength]; + System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); + metadata = new Id3Decoder().decode(id3Data, tagLength); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } + } else { + input.advancePeekPosition(framesLength); + } + + peekedId3Bytes += tagLength; + } + + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes); + } + /** * Returns a {@link Seeker} to seek using metadata read from {@code input}, which should provide * data from the start of the first frame in the stream. On returning, the input's position will diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index e93e9e3d9c..749c9b3542 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -132,7 +132,6 @@ import java.util.List; public static final int TYPE_vp08 = Util.getIntegerCodeForString("vp08"); public static final int TYPE_vp09 = Util.getIntegerCodeForString("vp09"); public static final int TYPE_vpcC = Util.getIntegerCodeForString("vpcC"); - public static final int TYPE_DASHES = Util.getIntegerCodeForString("----"); public final int type; @@ -299,7 +298,7 @@ import java.util.List; * @return The corresponding four character string. */ public static String getAtomTypeString(int type) { - return "" + (char) (type >> 24) + return "" + (char) ((type >> 24) & 0xFF) + (char) ((type >> 16) & 0xFF) + (char) ((type >> 8) & 0xFF) + (char) (type & 0xFF); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 0b2d5ec330..47cb3262e1 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.audio.Ac3Util; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.CodecSpecificDataUtil; import com.google.android.exoplayer2.util.MimeTypes; @@ -30,6 +31,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.HevcConfig; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -400,80 +402,54 @@ import java.util.List; * * @param udtaAtom The udta (user data) atom to decode. * @param isQuickTime True for QuickTime media. False otherwise. - * @param out {@link GaplessInfoHolder} to populate with gapless playback information. + * @return Parsed metadata, or null. */ - public static void parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime, GaplessInfoHolder out) { + public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { if (isQuickTime) { // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and // decode one. - return; + return null; } ParsableByteArray udtaData = udtaAtom.data; udtaData.setPosition(Atom.HEADER_SIZE); while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { + int atomPosition = udtaData.getPosition(); int atomSize = udtaData.readInt(); int atomType = udtaData.readInt(); if (atomType == Atom.TYPE_meta) { - udtaData.setPosition(udtaData.getPosition() - Atom.HEADER_SIZE); - udtaData.setLimit(udtaData.getPosition() + atomSize); - parseMetaAtom(udtaData, out); - break; + udtaData.setPosition(atomPosition); + return parseMetaAtom(udtaData, atomPosition + atomSize); } udtaData.skipBytes(atomSize - Atom.HEADER_SIZE); } + return null; } - private static void parseMetaAtom(ParsableByteArray data, GaplessInfoHolder out) { - data.skipBytes(Atom.FULL_HEADER_SIZE); - ParsableByteArray ilst = new ParsableByteArray(); - while (data.bytesLeft() >= Atom.HEADER_SIZE) { - int payloadSize = data.readInt() - Atom.HEADER_SIZE; - int atomType = data.readInt(); + private static Metadata parseMetaAtom(ParsableByteArray meta, int limit) { + meta.skipBytes(Atom.FULL_HEADER_SIZE); + while (meta.getPosition() < limit) { + int atomPosition = meta.getPosition(); + int atomSize = meta.readInt(); + int atomType = meta.readInt(); if (atomType == Atom.TYPE_ilst) { - ilst.reset(data.data, data.getPosition() + payloadSize); - ilst.setPosition(data.getPosition()); - parseIlst(ilst, out); - if (out.hasGaplessInfo()) { - return; - } + meta.setPosition(atomPosition); + return parseIlst(meta, atomPosition + atomSize); } - data.skipBytes(payloadSize); + meta.skipBytes(atomSize - Atom.HEADER_SIZE); } + return null; } - private static void parseIlst(ParsableByteArray ilst, GaplessInfoHolder out) { - while (ilst.bytesLeft() > 0) { - int position = ilst.getPosition(); - int endPosition = position + ilst.readInt(); - int type = ilst.readInt(); - if (type == Atom.TYPE_DASHES) { - String lastCommentMean = null; - String lastCommentName = null; - String lastCommentData = null; - while (ilst.getPosition() < endPosition) { - int length = ilst.readInt() - Atom.FULL_HEADER_SIZE; - int key = ilst.readInt(); - ilst.skipBytes(4); - if (key == Atom.TYPE_mean) { - lastCommentMean = ilst.readString(length); - } else if (key == Atom.TYPE_name) { - lastCommentName = ilst.readString(length); - } else if (key == Atom.TYPE_data) { - ilst.skipBytes(4); - lastCommentData = ilst.readString(length - 4); - } else { - ilst.skipBytes(length); - } - } - if (lastCommentName != null && lastCommentData != null - && "com.apple.iTunes".equals(lastCommentMean)) { - out.setFromComment(lastCommentName, lastCommentData); - break; - } - } else { - ilst.setPosition(endPosition); + private static Metadata parseIlst(ParsableByteArray ilst, int limit) { + ilst.skipBytes(Atom.HEADER_SIZE); + ArrayList entries = new ArrayList<>(); + while (ilst.getPosition() < limit) { + Metadata.Entry entry = MetadataUtil.parseIlstElement(ilst); + if (entry != null) { + entries.add(entry); } } + return entries.isEmpty() ? null : new Metadata(entries); } /** @@ -484,12 +460,9 @@ import java.util.List; */ private static long parseMvhd(ParsableByteArray mvhd) { mvhd.setPosition(Atom.HEADER_SIZE); - int fullAtom = mvhd.readInt(); int version = Atom.parseFullAtomVersion(fullAtom); - mvhd.skipBytes(version == 0 ? 8 : 16); - return mvhd.readUnsignedInt(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java new file mode 100644 index 0000000000..4bfef85d10 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.mp4; + +import android.util.Log; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; +import com.google.android.exoplayer2.metadata.id3.Id3Frame; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; + +/** + * Parses metadata items stored in ilst atoms. + */ +/* package */ final class MetadataUtil { + + private static final String TAG = "MetadataUtil"; + + // Codes that start with the copyright character (omitted) and have equivalent ID3 frames. + private static final int SHORT_TYPE_NAME_1 = Util.getIntegerCodeForString("nam"); + private static final int SHORT_TYPE_NAME_2 = Util.getIntegerCodeForString("trk"); + private static final int SHORT_TYPE_COMMENT = Util.getIntegerCodeForString("cmt"); + private static final int SHORT_TYPE_YEAR = Util.getIntegerCodeForString("day"); + private static final int SHORT_TYPE_ARTIST = Util.getIntegerCodeForString("ART"); + private static final int SHORT_TYPE_ENCODER = Util.getIntegerCodeForString("too"); + private static final int SHORT_TYPE_ALBUM = Util.getIntegerCodeForString("alb"); + private static final int SHORT_TYPE_COMPOSER_1 = Util.getIntegerCodeForString("com"); + private static final int SHORT_TYPE_COMPOSER_2 = Util.getIntegerCodeForString("wrt"); + private static final int SHORT_TYPE_LYRICS = Util.getIntegerCodeForString("lyr"); + private static final int SHORT_TYPE_GENRE = Util.getIntegerCodeForString("gen"); + + // Codes that have equivalent ID3 frames. + private static final int TYPE_COVER_ART = Util.getIntegerCodeForString("covr"); + private static final int TYPE_GENRE = Util.getIntegerCodeForString("gnre"); + private static final int TYPE_GROUPING = Util.getIntegerCodeForString("grp"); + private static final int TYPE_DISK_NUMBER = Util.getIntegerCodeForString("disk"); + private static final int TYPE_TRACK_NUMBER = Util.getIntegerCodeForString("trkn"); + private static final int TYPE_TEMPO = Util.getIntegerCodeForString("tmpo"); + private static final int TYPE_COMPILATION = Util.getIntegerCodeForString("cpil"); + private static final int TYPE_ALBUM_ARTIST = Util.getIntegerCodeForString("aART"); + private static final int TYPE_SORT_TRACK_NAME = Util.getIntegerCodeForString("sonm"); + private static final int TYPE_SORT_ALBUM = Util.getIntegerCodeForString("soal"); + private static final int TYPE_SORT_ARTIST = Util.getIntegerCodeForString("soar"); + private static final int TYPE_SORT_ALBUM_ARTIST = Util.getIntegerCodeForString("soaa"); + private static final int TYPE_SORT_COMPOSER = Util.getIntegerCodeForString("soco"); + + // Types that do not have equivalent ID3 frames. + private static final int TYPE_RATING = Util.getIntegerCodeForString("rtng"); + private static final int TYPE_GAPLESS_ALBUM = Util.getIntegerCodeForString("pgap"); + private static final int TYPE_TV_SORT_SHOW = Util.getIntegerCodeForString("sosn"); + private static final int TYPE_TV_SHOW = Util.getIntegerCodeForString("tvsh"); + + // Type for items that are intended for internal use by the player. + private static final int TYPE_INTERNAL = Util.getIntegerCodeForString("----"); + + // Standard genres. + private static final String[] STANDARD_GENRES = new String[] { + // These are the official ID3v1 genres. + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", + "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", + "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", + "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", + "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", + "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", + "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", + "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave", + "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", + "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", + "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", + "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", + "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", + "Hard Rock", + // These were made up by the authors of Winamp but backported into the ID3 spec. + "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", + "Bebob", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde", + "Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock", + "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour", + "Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", + "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club", + "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", + "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", + "Dance Hall", + // These were also invented by the Winamp folks but ignored by the ID3 authors. + "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", + "BritPop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta Rap", + "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian", + "Christian Rock", "Merengue", "Salsa", "Thrash Metal", "Anime", "Jpop", + "Synthpop" + }; + + private static final String LANGUAGE_UNDEFINED = "und"; + + private MetadataUtil() {} + + /** + * Parses a single ilst element from a {@link ParsableByteArray}. The element is read starting + * from the current position of the {@link ParsableByteArray}, and the position is advanced by + * the size of the element. The position is advanced even if the element's type is unrecognized. + * + * @param ilst Holds the data to be parsed. + * @return The parsed element, or null if the element's type was not recognized. + */ + public static Metadata.Entry parseIlstElement(ParsableByteArray ilst) { + int position = ilst.getPosition(); + int endPosition = position + ilst.readInt(); + int type = ilst.readInt(); + int typeTopByte = (type >> 24) & 0xFF; + try { + if (typeTopByte == '\u00A9' /* Copyright char */ + || typeTopByte == '\uFFFD' /* Replacement char */) { + int shortType = type & 0x00FFFFFF; + if (shortType == SHORT_TYPE_COMMENT) { + return parseCommentAttribute(type, ilst); + } else if (shortType == SHORT_TYPE_NAME_1 || shortType == SHORT_TYPE_NAME_2) { + return parseTextAttribute(type, "TIT2", ilst); + } else if (shortType == SHORT_TYPE_COMPOSER_1 || shortType == SHORT_TYPE_COMPOSER_2) { + return parseTextAttribute(type, "TCOM", ilst); + } else if (shortType == SHORT_TYPE_YEAR) { + return parseTextAttribute(type, "TDRC", ilst); + } else if (shortType == SHORT_TYPE_ARTIST) { + return parseTextAttribute(type, "TPE1", ilst); + } else if (shortType == SHORT_TYPE_ENCODER) { + return parseTextAttribute(type, "TSSE", ilst); + } else if (shortType == SHORT_TYPE_ALBUM) { + return parseTextAttribute(type, "TALB", ilst); + } else if (shortType == SHORT_TYPE_LYRICS) { + return parseTextAttribute(type, "USLT", ilst); + } else if (shortType == SHORT_TYPE_GENRE) { + return parseTextAttribute(type, "TCON", ilst); + } else if (shortType == TYPE_GROUPING) { + return parseTextAttribute(type, "TIT1", ilst); + } + } else if (type == TYPE_GENRE) { + return parseStandardGenreAttribute(ilst); + } else if (type == TYPE_DISK_NUMBER) { + return parseIndexAndCountAttribute(type, "TPOS", ilst); + } else if (type == TYPE_TRACK_NUMBER) { + return parseIndexAndCountAttribute(type, "TRCK", ilst); + } else if (type == TYPE_TEMPO) { + return parseUint8Attribute(type, "TBPM", ilst, true, false); + } else if (type == TYPE_COMPILATION) { + return parseUint8Attribute(type, "TCMP", ilst, true, true); + } else if (type == TYPE_COVER_ART) { + return parseCoverArt(ilst); + } else if (type == TYPE_ALBUM_ARTIST) { + return parseTextAttribute(type, "TPE2", ilst); + } else if (type == TYPE_SORT_TRACK_NAME) { + return parseTextAttribute(type, "TSOT", ilst); + } else if (type == TYPE_SORT_ALBUM) { + return parseTextAttribute(type, "TSO2", ilst); + } else if (type == TYPE_SORT_ARTIST) { + return parseTextAttribute(type, "TSOA", ilst); + } else if (type == TYPE_SORT_ALBUM_ARTIST) { + return parseTextAttribute(type, "TSOP", ilst); + } else if (type == TYPE_SORT_COMPOSER) { + return parseTextAttribute(type, "TSOC", ilst); + } else if (type == TYPE_RATING) { + return parseUint8Attribute(type, "ITUNESADVISORY", ilst, false, false); + } else if (type == TYPE_GAPLESS_ALBUM) { + return parseUint8Attribute(type, "ITUNESGAPLESS", ilst, false, true); + } else if (type == TYPE_TV_SORT_SHOW) { + return parseTextAttribute(type, "TVSHOWSORT", ilst); + } else if (type == TYPE_TV_SHOW) { + return parseTextAttribute(type, "TVSHOW", ilst); + } else if (type == TYPE_INTERNAL) { + return parseInternalAttribute(ilst, endPosition); + } + Log.d(TAG, "Skipped unknown metadata entry: " + Atom.getAtomTypeString(type)); + return null; + } finally { + ilst.setPosition(endPosition); + } + } + + private static TextInformationFrame parseTextAttribute(int type, String id, + ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new TextInformationFrame(id, value); + } + Log.w(TAG, "Failed to parse text attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static CommentFrame parseCommentAttribute(int type, ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new CommentFrame(LANGUAGE_UNDEFINED, value, value); + } + Log.w(TAG, "Failed to parse comment attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static Id3Frame parseUint8Attribute(int type, String id, ParsableByteArray data, + boolean isTextInformationFrame, boolean isBoolean) { + int value = parseUint8AttributeValue(data); + if (isBoolean) { + value = Math.min(1, value); + } + if (value >= 0) { + return isTextInformationFrame ? new TextInformationFrame(id, Integer.toString(value)) + : new CommentFrame(LANGUAGE_UNDEFINED, id, Integer.toString(value)); + } + Log.w(TAG, "Failed to parse uint8 attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static TextInformationFrame parseIndexAndCountAttribute(int type, String attributeName, + ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data && atomSize >= 22) { + data.skipBytes(10); // version (1), flags (3), empty (4), empty (2) + int index = data.readUnsignedShort(); + if (index > 0) { + String description = "" + index; + int count = data.readUnsignedShort(); + if (count > 0) { + description += "/" + count; + } + return new TextInformationFrame(attributeName, description); + } + } + Log.w(TAG, "Failed to parse index/count attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static TextInformationFrame parseStandardGenreAttribute(ParsableByteArray data) { + int genreCode = parseUint8AttributeValue(data); + String genreString = (0 < genreCode && genreCode <= STANDARD_GENRES.length) + ? STANDARD_GENRES[genreCode - 1] : null; + if (genreString != null) { + return new TextInformationFrame("TCON", genreString); + } + Log.w(TAG, "Failed to parse standard genre code"); + return null; + } + + private static ApicFrame parseCoverArt(ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + int fullVersionInt = data.readInt(); + int flags = Atom.parseFullAtomFlags(fullVersionInt); + String mimeType = flags == 13 ? "image/jpeg" : flags == 14 ? "image/png" : null; + if (mimeType == null) { + Log.w(TAG, "Unrecognized cover art flags: " + flags); + return null; + } + data.skipBytes(4); // empty (4) + byte[] pictureData = new byte[atomSize - 16]; + data.readBytes(pictureData, 0, pictureData.length); + return new ApicFrame(mimeType, null, 3 /* Cover (front) */, pictureData); + } + Log.w(TAG, "Failed to parse cover art attribute"); + return null; + } + + private static Id3Frame parseInternalAttribute(ParsableByteArray data, int endPosition) { + String domain = null; + String name = null; + int dataAtomPosition = -1; + int dataAtomSize = -1; + while (data.getPosition() < endPosition) { + int atomPosition = data.getPosition(); + int atomSize = data.readInt(); + int atomType = data.readInt(); + data.skipBytes(4); // version (1), flags (3) + if (atomType == Atom.TYPE_mean) { + domain = data.readNullTerminatedString(atomSize - 12); + } else if (atomType == Atom.TYPE_name) { + name = data.readNullTerminatedString(atomSize - 12); + } else { + if (atomType == Atom.TYPE_data) { + dataAtomPosition = atomPosition; + dataAtomSize = atomSize; + } + data.skipBytes(atomSize - 12); + } + } + if (!"com.apple.iTunes".equals(domain) || !"iTunSMPB".equals(name) || dataAtomPosition == -1) { + // We're only interested in iTunSMPB. + return null; + } + data.setPosition(dataAtomPosition); + data.skipBytes(16); // size (4), type (4), version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(dataAtomSize - 16); + return new CommentFrame(LANGUAGE_UNDEFINED, name, value); + } + + private static int parseUint8AttributeValue(ParsableByteArray data) { + data.skipBytes(4); // atomSize + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + return data.readUnsignedByte(); + } + Log.w(TAG, "Failed to parse uint8 attribute value"); + return -1; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index 467ec7a4fa..4c52622c78 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -310,10 +311,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { List tracks = new ArrayList<>(); long earliestSampleOffset = Long.MAX_VALUE; + Metadata metadata = null; GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder(); Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta); if (udta != null) { - AtomParsers.parseUdta(udta, isQuickTime, gaplessInfoHolder); + metadata = AtomParsers.parseUdta(udta, isQuickTime); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } } for (int i = 0; i < moov.containerChildren.size(); i++) { @@ -340,9 +345,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Allow ten source samples per output sample, like the platform extractor. int maxInputSize = trackSampleTable.maximumSize + 3 * 10; Format format = track.format.copyWithMaxInputSize(maxInputSize); - if (track.type == C.TRACK_TYPE_AUDIO && gaplessInfoHolder.hasGaplessInfo()) { - format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding); + if (track.type == C.TRACK_TYPE_AUDIO) { + if (gaplessInfoHolder.hasGaplessInfo()) { + format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay, + gaplessInfoHolder.encoderPadding); + } + if (metadata != null) { + format = format.copyWithMetadata(metadata); + } } mp4Track.trackOutput.format(format); diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java b/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java new file mode 100644 index 0000000000..40c05a5602 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata; + +import android.os.Parcel; +import android.os.Parcelable; +import java.util.Arrays; +import java.util.List; + +/** + * A collection of metadata entries. + */ +public final class Metadata implements Parcelable { + + /** + * A metadata entry. + */ + public interface Entry extends Parcelable {} + + private final Entry[] entries; + + /** + * @param entries The metadata entries. + */ + public Metadata(Entry... entries) { + this.entries = entries == null ? new Entry[0] : entries; + } + + /** + * @param entries The metadata entries. + */ + public Metadata(List entries) { + if (entries != null) { + this.entries = new Entry[entries.size()]; + entries.toArray(this.entries); + } else { + this.entries = new Entry[0]; + } + } + + /* package */ Metadata(Parcel in) { + entries = new Metadata.Entry[in.readInt()]; + for (int i = 0; i < entries.length; i++) { + entries[i] = in.readParcelable(Entry.class.getClassLoader()); + } + } + + /** + * Returns the number of metadata entries. + */ + public int length() { + return entries.length; + } + + /** + * Returns the entry at the specified index. + * + * @param index The index of the entry. + * @return The entry at the specified index. + */ + public Metadata.Entry get(int index) { + return entries[index]; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Metadata other = (Metadata) obj; + return Arrays.equals(entries, other.entries); + } + + @Override + public int hashCode() { + return Arrays.hashCode(entries); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(entries.length); + for (Entry entry : entries) { + dest.writeParcelable(entry, 0); + } + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Metadata createFromParcel(Parcel in) { + return new Metadata(in); + } + + @Override + public Metadata[] newArray(int size) { + return new Metadata[0]; + } + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java index 7cde1f243d..a73311f16b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java @@ -17,10 +17,8 @@ package com.google.android.exoplayer2.metadata; /** * Decodes metadata from binary data. - * - * @param The type of the metadata. */ -public interface MetadataDecoder { +public interface MetadataDecoder { /** * Checks whether the decoder supports a given mime type. @@ -38,6 +36,6 @@ public interface MetadataDecoder { * @return The decoded metadata object. * @throws MetadataDecoderException If a problem occurred decoding the data. */ - T decode(byte[] data, int size) throws MetadataDecoderException; + Metadata decode(byte[] data, int size) throws MetadataDecoderException; } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index aca38a1258..ff1364610b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -30,38 +30,34 @@ import java.nio.ByteBuffer; /** * A renderer for metadata. - * - * @param The type of the metadata. */ -public final class MetadataRenderer extends BaseRenderer implements Callback { +public final class MetadataRenderer extends BaseRenderer implements Callback { /** * Receives output from a {@link MetadataRenderer}. - * - * @param The type of the metadata. */ - public interface Output { + public interface Output { /** * Called each time there is a metadata associated with current playback time. * * @param metadata The metadata. */ - void onMetadata(T metadata); + void onMetadata(Metadata metadata); } private static final int MSG_INVOKE_RENDERER = 0; - private final MetadataDecoder metadataDecoder; - private final Output output; + private final MetadataDecoder metadataDecoder; + private final Output output; private final Handler outputHandler; private final FormatHolder formatHolder; private final DecoderInputBuffer buffer; private boolean inputStreamEnded; private long pendingMetadataTimestamp; - private T pendingMetadata; + private Metadata pendingMetadata; /** * @param output The output. @@ -72,8 +68,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback * called directly on the player's internal rendering thread. * @param metadataDecoder A decoder for the metadata. */ - public MetadataRenderer(Output output, Looper outputLooper, - MetadataDecoder metadataDecoder) { + public MetadataRenderer(Output output, Looper outputLooper, MetadataDecoder metadataDecoder) { super(C.TRACK_TYPE_METADATA); this.output = Assertions.checkNotNull(output); this.outputHandler = outputLooper == null ? null : new Handler(outputLooper, this); @@ -137,7 +132,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback return true; } - private void invokeRenderer(T metadata) { + private void invokeRenderer(Metadata metadata) { if (outputHandler != null) { outputHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); } else { @@ -150,13 +145,13 @@ public final class MetadataRenderer extends BaseRenderer implements Callback public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_INVOKE_RENDERER: - invokeRendererInternal((T) msg.obj); + invokeRendererInternal((Metadata) msg.obj); return true; } return false; } - private void invokeRendererInternal(T metadata) { + private void invokeRendererInternal(Metadata metadata) { output.onMetadata(metadata); } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java index d2a04bdb94..c64be24a31 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * APIC (Attached Picture) ID3 frame. */ @@ -35,4 +40,58 @@ public final class ApicFrame extends Id3Frame { this.pictureData = pictureData; } + /* package */ ApicFrame(Parcel in) { + super(ID); + mimeType = in.readString(); + description = in.readString(); + pictureType = in.readInt(); + pictureData = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ApicFrame other = (ApicFrame) obj; + return pictureType == other.pictureType && Util.areEqual(mimeType, other.mimeType) + && Util.areEqual(description, other.description) + && Arrays.equals(pictureData, other.pictureData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + pictureType; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(pictureData); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(description); + dest.writeInt(pictureType); + dest.writeByteArray(pictureData); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public ApicFrame createFromParcel(Parcel in) { + return new ApicFrame(in); + } + + @Override + public ApicFrame[] newArray(int size) { + return new ApicFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java index 5bc4ce3829..f662c1d06f 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import java.util.Arrays; + /** * Binary ID3 frame. */ @@ -22,9 +26,55 @@ public final class BinaryFrame extends Id3Frame { public final byte[] data; - public BinaryFrame(String type, byte[] data) { - super(type); + public BinaryFrame(String id, byte[] data) { + super(id); this.data = data; } + /* package */ BinaryFrame(Parcel in) { + super(in.readString()); + data = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BinaryFrame other = (BinaryFrame) obj; + return id.equals(other.id) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public BinaryFrame createFromParcel(Parcel in) { + return new BinaryFrame(in); + } + + @Override + public BinaryFrame[] newArray(int size) { + return new BinaryFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java new file mode 100644 index 0000000000..b7cc937ac4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.id3; + +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + +/** + * Comment ID3 frame. + */ +public final class CommentFrame extends Id3Frame { + + public static final String ID = "COMM"; + + public final String language; + public final String description; + public final String text; + + public CommentFrame(String language, String description, String text) { + super(ID); + this.language = language; + this.description = description; + this.text = text; + } + + /* package */ CommentFrame(Parcel in) { + super(ID); + language = in.readString(); + description = in.readString(); + text = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + CommentFrame other = (CommentFrame) obj; + return Util.areEqual(description, other.description) && Util.areEqual(language, other.language) + && Util.areEqual(text, other.text); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (language != null ? language.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (text != null ? text.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(language); + dest.writeString(text); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public CommentFrame createFromParcel(Parcel in) { + return new CommentFrame(in); + } + + @Override + public CommentFrame[] newArray(int size) { + return new CommentFrame[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java index 4b77a69b27..79e145fc7c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * GEOB (General Encapsulated Object) ID3 frame. */ @@ -35,4 +40,57 @@ public final class GeobFrame extends Id3Frame { this.data = data; } + /* package */ GeobFrame(Parcel in) { + super(ID); + mimeType = in.readString(); + filename = in.readString(); + description = in.readString(); + data = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GeobFrame other = (GeobFrame) obj; + return Util.areEqual(mimeType, other.mimeType) && Util.areEqual(filename, other.filename) + && Util.areEqual(description, other.description) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (filename != null ? filename.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(filename); + dest.writeString(description); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public GeobFrame createFromParcel(Parcel in) { + return new GeobFrame(in); + } + + @Override + public GeobFrame[] newArray(int size) { + return new GeobFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 35960e39d2..05bff672a4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -15,21 +15,33 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.util.Log; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; -import com.google.android.exoplayer2.metadata.MetadataDecoderException; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Locale; /** - * Decodes individual TXXX text frames from raw ID3 data. + * Decodes ID3 tags. */ -public final class Id3Decoder implements MetadataDecoder> { +public final class Id3Decoder implements MetadataDecoder { + + private static final String TAG = "Id3Decoder"; + + /** + * The first three bytes of a well formed ID3 tag header. + */ + public static final int ID3_TAG = Util.getIntegerCodeForString("ID3"); + /** + * Length of an ID3 tag header. + */ + public static final int ID3_HEADER_LENGTH = 10; private static final int ID3_TEXT_ENCODING_ISO_8859_1 = 0; private static final int ID3_TEXT_ENCODING_UTF_16 = 1; @@ -42,51 +54,46 @@ public final class Id3Decoder implements MetadataDecoder> { } @Override - public List decode(byte[] data, int size) throws MetadataDecoderException { + public Metadata decode(byte[] data, int size) { List id3Frames = new ArrayList<>(); ParsableByteArray id3Data = new ParsableByteArray(data, size); - int id3Size = decodeId3Header(id3Data); - while (id3Size > 0) { - int frameId0 = id3Data.readUnsignedByte(); - int frameId1 = id3Data.readUnsignedByte(); - int frameId2 = id3Data.readUnsignedByte(); - int frameId3 = id3Data.readUnsignedByte(); - int frameSize = id3Data.readSynchSafeInt(); - if (frameSize <= 1) { - break; - } + Id3Header id3Header = decodeHeader(id3Data); + if (id3Header == null) { + return null; + } - // Skip frame flags. - id3Data.skipBytes(2); + int startPosition = id3Data.getPosition(); + int framesSize = id3Header.framesSize; + if (id3Header.isUnsynchronized) { + framesSize = removeUnsynchronization(id3Data, id3Header.framesSize); + } + id3Data.setLimit(startPosition + framesSize); - try { - Id3Frame frame; - if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && frameId3 == 'X') { - frame = decodeTxxxFrame(id3Data, frameSize); - } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { - frame = decodePrivFrame(id3Data, frameSize); - } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' && frameId3 == 'B') { - frame = decodeGeobFrame(id3Data, frameSize); - } else if (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C') { - frame = decodeApicFrame(id3Data, frameSize); - } else if (frameId0 == 'T') { - String id = String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); - frame = decodeTextInformationFrame(id3Data, frameSize, id); + boolean unsignedIntFrameSizeHack = false; + if (id3Header.majorVersion == 4) { + if (!validateV4Frames(id3Data, false)) { + if (validateV4Frames(id3Data, true)) { + unsignedIntFrameSizeHack = true; } else { - String id = String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); - frame = decodeBinaryFrame(id3Data, frameSize, id); + Log.w(TAG, "Failed to validate V4 ID3 tag"); + return null; } - id3Frames.add(frame); - id3Size -= frameSize + 10 /* header size */; - } catch (UnsupportedEncodingException e) { - throw new MetadataDecoderException("Unsupported encoding", e); } } - return Collections.unmodifiableList(id3Frames); + int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10; + while (id3Data.bytesLeft() >= frameHeaderSize) { + Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack); + if (frame != null) { + id3Frames.add(frame); + } + } + + return new Metadata(id3Frames); } + // TODO: Move the following three methods nearer to the bottom of the file. private static int indexOfEos(byte[] data, int fromIndex, int encoding) { int terminationPos = indexOfZeroByte(data, fromIndex); @@ -95,7 +102,7 @@ public final class Id3Decoder implements MetadataDecoder> { return terminationPos; } - // Otherwise look for a second zero byte. + // Otherwise ensure an even index and look for a second zero byte. while (terminationPos < data.length - 1) { if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) { return terminationPos; @@ -121,38 +128,207 @@ public final class Id3Decoder implements MetadataDecoder> { } /** - * @param id3Buffer A {@link ParsableByteArray} from which data should be read. - * @return The size of ID3 frames in bytes, excluding the header and footer. - * @throws MetadataDecoderException If ID3 file identifier != "ID3". + * @param data A {@link ParsableByteArray} from which the header should be read. + * @return The parsed header, or null if the ID3 tag is unsupported. */ - private static int decodeId3Header(ParsableByteArray id3Buffer) throws MetadataDecoderException { - int id1 = id3Buffer.readUnsignedByte(); - int id2 = id3Buffer.readUnsignedByte(); - int id3 = id3Buffer.readUnsignedByte(); - if (id1 != 'I' || id2 != 'D' || id3 != '3') { - throw new MetadataDecoderException(String.format(Locale.US, - "Unexpected ID3 file identifier, expected \"ID3\", actual \"%c%c%c\".", id1, id2, id3)); + private static Id3Header decodeHeader(ParsableByteArray data) { + if (data.bytesLeft() < ID3_HEADER_LENGTH) { + Log.w(TAG, "Data too short to be an ID3 tag"); + return null; } - id3Buffer.skipBytes(2); // Skip version. - int flags = id3Buffer.readUnsignedByte(); - int id3Size = id3Buffer.readSynchSafeInt(); + int id = data.readUnsignedInt24(); + if (id != ID3_TAG) { + Log.w(TAG, "Unexpected first three bytes of ID3 tag header: " + id); + return null; + } - // Check if extended header presents. - if ((flags & 0x2) != 0) { - int extendedHeaderSize = id3Buffer.readSynchSafeInt(); - if (extendedHeaderSize > 4) { - id3Buffer.skipBytes(extendedHeaderSize - 4); + int majorVersion = data.readUnsignedByte(); + data.skipBytes(1); // Skip minor version. + int flags = data.readUnsignedByte(); + int framesSize = data.readSynchSafeInt(); + + if (majorVersion == 2) { + boolean isCompressed = (flags & 0x40) != 0; + if (isCompressed) { + Log.w(TAG, "Skipped ID3 tag with majorVersion=2 and undefined compression scheme"); + return null; } - id3Size -= extendedHeaderSize; + } else if (majorVersion == 3) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readInt(); // Size excluding size field. + data.skipBytes(extendedHeaderSize); + framesSize -= (extendedHeaderSize + 4); + } + } else if (majorVersion == 4) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readSynchSafeInt(); // Size including size field. + data.skipBytes(extendedHeaderSize - 4); + framesSize -= extendedHeaderSize; + } + boolean hasFooter = (flags & 0x10) != 0; + if (hasFooter) { + framesSize -= 10; + } + } else { + Log.w(TAG, "Skipped ID3 tag with unsupported majorVersion=" + majorVersion); + return null; } - // Check if footer presents. - if ((flags & 0x8) != 0) { - id3Size -= 10; + // isUnsynchronized is advisory only in version 4. Frame level flags are used instead. + boolean isUnsynchronized = majorVersion < 4 && (flags & 0x80) != 0; + return new Id3Header(majorVersion, isUnsynchronized, framesSize); + } + + private static boolean validateV4Frames(ParsableByteArray id3Data, + boolean unsignedIntFrameSizeHack) { + int startPosition = id3Data.getPosition(); + try { + while (id3Data.bytesLeft() >= 10) { + int id = id3Data.readInt(); + int frameSize = id3Data.readUnsignedIntToInt(); + int flags = id3Data.readUnsignedShort(); + if (id == 0 && frameSize == 0 && flags == 0) { + return true; + } else { + if (!unsignedIntFrameSizeHack) { + // Parse the data size as a synchsafe integer, as per the spec. + if ((frameSize & 0x808080L) != 0) { + return false; + } + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + int minimumFrameSize = 0; + if ((flags & 0x0040) != 0 /* hasGroupIdentifier */) { + minimumFrameSize++; + } + if ((flags & 0x0001) != 0 /* hasDataLength */) { + minimumFrameSize += 4; + } + if (frameSize < minimumFrameSize) { + return false; + } + if (id3Data.bytesLeft() < frameSize) { + return false; + } + id3Data.skipBytes(frameSize); // flags + } + } + return true; + } finally { + id3Data.setPosition(startPosition); + } + } + + private static Id3Frame decodeFrame(int majorVersion, ParsableByteArray id3Data, + boolean unsignedIntFrameSizeHack) { + int frameId0 = id3Data.readUnsignedByte(); + int frameId1 = id3Data.readUnsignedByte(); + int frameId2 = id3Data.readUnsignedByte(); + int frameId3 = majorVersion >= 3 ? id3Data.readUnsignedByte() : 0; + + int frameSize; + if (majorVersion == 4) { + frameSize = id3Data.readUnsignedIntToInt(); + if (!unsignedIntFrameSizeHack) { + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + } else if (majorVersion == 3) { + frameSize = id3Data.readUnsignedIntToInt(); + } else /* id3Header.majorVersion == 2 */ { + frameSize = id3Data.readUnsignedInt24(); } - return id3Size; + int flags = majorVersion >= 3 ? id3Data.readUnsignedShort() : 0; + if (frameId0 == 0 && frameId1 == 0 && frameId2 == 0 && frameId3 == 0 && frameSize == 0 + && flags == 0) { + // We must be reading zero padding at the end of the tag. + id3Data.setPosition(id3Data.limit()); + return null; + } + + int nextFramePosition = id3Data.getPosition() + frameSize; + if (nextFramePosition > id3Data.limit()) { + Log.w(TAG, "Frame size exceeds remaining tag data"); + id3Data.setPosition(id3Data.limit()); + return null; + } + + // Frame flags. + boolean isCompressed = false; + boolean isEncrypted = false; + boolean isUnsynchronized = false; + boolean hasDataLength = false; + boolean hasGroupIdentifier = false; + if (majorVersion == 3) { + isCompressed = (flags & 0x0080) != 0; + isEncrypted = (flags & 0x0040) != 0; + hasGroupIdentifier = (flags & 0x0020) != 0; + hasDataLength = isCompressed; + } else if (majorVersion == 4) { + hasGroupIdentifier = (flags & 0x0040) != 0; + isCompressed = (flags & 0x0008) != 0; + isEncrypted = (flags & 0x0004) != 0; + isUnsynchronized = (flags & 0x0002) != 0; + hasDataLength = (flags & 0x0001) != 0; + } + + if (isCompressed || isEncrypted) { + Log.w(TAG, "Skipping unsupported compressed or encrypted frame"); + id3Data.setPosition(nextFramePosition); + return null; + } + + if (hasGroupIdentifier) { + frameSize--; + id3Data.skipBytes(1); + } + if (hasDataLength) { + frameSize -= 4; + id3Data.skipBytes(4); + } + if (isUnsynchronized) { + frameSize = removeUnsynchronization(id3Data, frameSize); + } + + try { + Id3Frame frame; + if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' + && (majorVersion == 2 || frameId3 == 'X')) { + frame = decodeTxxxFrame(id3Data, frameSize); + } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { + frame = decodePrivFrame(id3Data, frameSize); + } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' + && (frameId3 == 'B' || majorVersion == 2)) { + frame = decodeGeobFrame(id3Data, frameSize); + } else if (majorVersion == 2 ? (frameId0 == 'P' && frameId1 == 'I' && frameId2 == 'C') + : (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C')) { + frame = decodeApicFrame(id3Data, frameSize, majorVersion); + } else if (frameId0 == 'T') { + String id = majorVersion == 2 + ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) + : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + frame = decodeTextInformationFrame(id3Data, frameSize, id); + } else if (frameId0 == 'C' && frameId1 == 'O' && frameId2 == 'M' + && (frameId3 == 'M' || majorVersion == 2)) { + frame = decodeCommentFrame(id3Data, frameSize); + } else { + String id = majorVersion == 2 + ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) + : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + frame = decodeBinaryFrame(id3Data, frameSize, id); + } + return frame; + } catch (UnsupportedEncodingException e) { + Log.w(TAG, "Unsupported character encoding"); + return null; + } finally { + id3Data.setPosition(nextFramePosition); + } } private static TxxxFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) @@ -214,16 +390,29 @@ public final class Id3Decoder implements MetadataDecoder> { return new GeobFrame(mimeType, filename, description, objectData); } - private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize, + int majorVersion) throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); String charset = getCharsetName(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); - int mimeTypeEndIndex = indexOfZeroByte(data, 0); - String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1"); + String mimeType; + int mimeTypeEndIndex; + if (majorVersion == 2) { + mimeTypeEndIndex = 2; + mimeType = "image/" + new String(data, 0, 3, "ISO-8859-1").toLowerCase(); + if (mimeType.equals("image/jpg")) { + mimeType = "image/jpeg"; + } + } else { + mimeTypeEndIndex = indexOfZeroByte(data, 0); + mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1").toLowerCase(); + if (mimeType.indexOf('/') == -1) { + mimeType = "image/" + mimeType; + } + } int pictureType = data[mimeTypeEndIndex + 1] & 0xFF; @@ -238,6 +427,28 @@ public final class Id3Decoder implements MetadataDecoder> { return new ApicFrame(mimeType, description, pictureType, pictureData); } + private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[3]; + id3Data.readBytes(data, 0, 3); + String language = new String(data, 0, 3); + + data = new byte[frameSize - 4]; + id3Data.readBytes(data, 0, frameSize - 4); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + int textStartIndex = descriptionEndIndex + delimiterLength(encoding); + int textEndIndex = indexOfEos(data, textStartIndex, encoding); + String text = new String(data, textStartIndex, textEndIndex - textStartIndex, charset); + + return new CommentFrame(language, description, text); + } + private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); @@ -260,6 +471,25 @@ public final class Id3Decoder implements MetadataDecoder> { return new BinaryFrame(id, frame); } + /** + * Performs in-place removal of unsynchronization for {@code length} bytes starting from + * {@link ParsableByteArray#getPosition()} + * + * @param data Contains the data to be processed. + * @param length The length of the data to be processed. + * @return The length of the data after processing. + */ + private static int removeUnsynchronization(ParsableByteArray data, int length) { + byte[] bytes = data.data; + for (int i = data.getPosition(); i + 1 < length; i++) { + if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { + System.arraycopy(bytes, i + 2, bytes, i + 1, length - i - 2); + length--; + } + } + return length; + } + /** * Maps encoding byte from ID3v2 frame to a Charset. * @param encodingByte The value of encoding byte from ID3v2 frame. @@ -280,4 +510,18 @@ public final class Id3Decoder implements MetadataDecoder> { } } + private static final class Id3Header { + + private final int majorVersion; + private final boolean isUnsynchronized; + private final int framesSize; + + public Id3Header(int majorVersion, boolean isUnsynchronized, int framesSize) { + this.majorVersion = majorVersion; + this.isUnsynchronized = isUnsynchronized; + this.framesSize = framesSize; + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java index 903b32da4f..9948f730eb 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java @@ -15,10 +15,13 @@ */ package com.google.android.exoplayer2.metadata.id3; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Assertions; + /** * Base class for ID3 frames. */ -public abstract class Id3Frame { +public abstract class Id3Frame implements Metadata.Entry { /** * The frame ID. @@ -26,7 +29,12 @@ public abstract class Id3Frame { public final String id; public Id3Frame(String id) { - this.id = id; + this.id = Assertions.checkNotNull(id); + } + + @Override + public int describeContents() { + return 0; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java index bbfbd96b84..fe55f5ddc0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * PRIV (Private) ID3 frame. */ @@ -31,4 +36,50 @@ public final class PrivFrame extends Id3Frame { this.privateData = privateData; } + /* package */ PrivFrame(Parcel in) { + super(ID); + owner = in.readString(); + privateData = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PrivFrame other = (PrivFrame) obj; + return Util.areEqual(owner, other.owner) && Arrays.equals(privateData, other.privateData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (owner != null ? owner.hashCode() : 0); + result = 31 * result + Arrays.hashCode(privateData); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(owner); + dest.writeByteArray(privateData); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public PrivFrame createFromParcel(Parcel in) { + return new PrivFrame(in); + } + + @Override + public PrivFrame[] newArray(int size) { + return new PrivFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java index ec05a8ff4b..b8c061fd0a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + /** * Text information ("T000" - "TZZZ", excluding "TXXX") ID3 frame. */ @@ -27,4 +31,50 @@ public final class TextInformationFrame extends Id3Frame { this.description = description; } + /* package */ TextInformationFrame(Parcel in) { + super(in.readString()); + description = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TextInformationFrame other = (TextInformationFrame) obj; + return id.equals(other.id) && Util.areEqual(description, other.description); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(description); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public TextInformationFrame createFromParcel(Parcel in) { + return new TextInformationFrame(in); + } + + @Override + public TextInformationFrame[] newArray(int size) { + return new TextInformationFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java index 6593c2f120..5c24e70ef4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + /** * TXXX (User defined text information) ID3 frame. */ @@ -31,4 +35,50 @@ public final class TxxxFrame extends Id3Frame { this.value = value; } + /* package */ TxxxFrame(Parcel in) { + super(ID); + description = in.readString(); + value = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TxxxFrame other = (TxxxFrame) obj; + return Util.areEqual(description, other.description) && Util.areEqual(value, other.value); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(description); + dest.writeString(value); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public TxxxFrame createFromParcel(Parcel in) { + return new TxxxFrame(in); + } + + @Override + public TxxxFrame[] newArray(int size) { + return new TxxxFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index b306fbf76e..05c29ca032 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -300,9 +300,9 @@ public final class ParsableByteArray { */ public int readLittleEndianInt() { return (data[position++] & 0xFF) - | (data[position++] & 0xFF) << 8 - | (data[position++] & 0xFF) << 16 - | (data[position++] & 0xFF) << 24; + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 24; } /** @@ -423,6 +423,24 @@ public final class ParsableByteArray { return readString(length, Charset.defaultCharset()); } + /** + * Reads the next {@code length} bytes as UTF-8 characters. A terminating NUL byte is ignored, + * if present. + * + * @param length The number of bytes to read. + * @return The string encoded by the bytes. + */ + public String readNullTerminatedString(int length) { + int stringLength = length; + int lastIndex = position + length - 1; + if (lastIndex < limit && data[lastIndex] == 0) { + stringLength--; + } + String result = new String(data, position, stringLength, Charset.defaultCharset()); + position += length; + return result; + } + /** * Reads the next {@code length} bytes as characters in the specified {@link Charset}. *