diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java index a64ceead46..63618952fd 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer.demo.full.player.DemoPlayer; import com.google.android.exoplayer.demo.full.player.DemoPlayer.RendererBuilder; import com.google.android.exoplayer.demo.full.player.HlsRendererBuilder; import com.google.android.exoplayer.demo.full.player.SmoothStreamingRendererBuilder; +import com.google.android.exoplayer.metadata.Metadata; import com.google.android.exoplayer.text.CaptionStyleCompat; import com.google.android.exoplayer.text.SubtitleView; import com.google.android.exoplayer.util.Util; @@ -38,6 +39,7 @@ import android.graphics.Point; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.Display; import android.view.Menu; import android.view.MenuItem; @@ -54,11 +56,15 @@ import android.widget.PopupMenu; import android.widget.PopupMenu.OnMenuItemClickListener; import android.widget.TextView; +import java.util.List; + /** * An activity that plays media using {@link DemoPlayer}. */ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callback, OnClickListener, - DemoPlayer.Listener, DemoPlayer.TextListener { + DemoPlayer.Listener, DemoPlayer.TextListener, DemoPlayer.MetadataListener { + + private static final String TAG = "FullPlayerActivity"; private static final float CAPTION_LINE_HEIGHT_RATIO = 0.0533f; private static final int MENU_GROUP_TRACKS = 1; @@ -187,6 +193,7 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba player = new DemoPlayer(getRendererBuilder()); player.addListener(this); player.setTextListener(this); + player.setMetadataListener(this); player.seekTo(playerPosition); playerNeedsPrepare = true; mediaController.setMediaPlayer(player.getPlayerControl()); @@ -403,6 +410,16 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba } } + // DemoPlayer.MetadataListener implementation + + @Override + public void onMetadata(List metadata) { + for (int i = 0; i < metadata.size(); i++) { + Metadata next = metadata.get(i); + Log.i(TAG, "ID3 TimedMetadata: key=" + next.key + ", value=" + next.value); + } + } + // SurfaceHolder.Callback implementation @Override diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java index b88ce89157..b6a68bc98f 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java @@ -26,6 +26,8 @@ import com.google.android.exoplayer.TrackRenderer; import com.google.android.exoplayer.chunk.ChunkSampleSource; import com.google.android.exoplayer.chunk.MultiTrackChunkSource; import com.google.android.exoplayer.drm.StreamingDrmSessionManager; +import com.google.android.exoplayer.metadata.Metadata; +import com.google.android.exoplayer.metadata.MetadataTrackRenderer; import com.google.android.exoplayer.text.TextTrackRenderer; import com.google.android.exoplayer.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer.util.PlayerControl; @@ -36,6 +38,7 @@ import android.os.Looper; import android.view.Surface; import java.io.IOException; +import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** @@ -46,7 +49,7 @@ import java.util.concurrent.CopyOnWriteArrayList; public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventListener, DefaultBandwidthMeter.EventListener, MediaCodecVideoTrackRenderer.EventListener, MediaCodecAudioTrackRenderer.EventListener, TextTrackRenderer.TextRenderer, - StreamingDrmSessionManager.EventListener { + MetadataTrackRenderer.MetadataRenderer, StreamingDrmSessionManager.EventListener { /** * Builds renderers for the player. @@ -134,6 +137,13 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi void onText(String text); } + /** + * A listener for receiving metadata parsed from the media stream. + */ + public interface MetadataListener { + void onMetadata(List metadata); + } + // Constants pulled into this class for convenience. public static final int STATE_IDLE = ExoPlayer.STATE_IDLE; public static final int STATE_PREPARING = ExoPlayer.STATE_PREPARING; @@ -144,11 +154,12 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi public static final int DISABLED_TRACK = -1; public static final int PRIMARY_TRACK = 0; - public static final int RENDERER_COUNT = 4; + public static final int RENDERER_COUNT = 5; public static final int TYPE_VIDEO = 0; public static final int TYPE_AUDIO = 1; public static final int TYPE_TEXT = 2; - public static final int TYPE_DEBUG = 3; + public static final int TYPE_TIMED_METADATA = 3; + public static final int TYPE_DEBUG = 4; private static final int RENDERER_BUILDING_STATE_IDLE = 1; private static final int RENDERER_BUILDING_STATE_BUILDING = 2; @@ -173,6 +184,7 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi private int[] selectedTracks; private TextListener textListener; + private MetadataListener metadataListener; private InternalErrorListener internalErrorListener; private InfoListener infoListener; @@ -214,6 +226,10 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi textListener = listener; } + public void setMetadataListener(MetadataListener listener) { + metadataListener = listener; + } + public void setSurface(Surface surface) { this.surface = surface; pushSurfaceAndVideoTrack(false); @@ -458,6 +474,13 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi } } + @Override + public void onMetadata(List metadata) { + if (metadataListener != null) { + metadataListener.onMetadata(metadata); + } + } + @Override public void onPlayWhenReadyCommitted() { // Do nothing. diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java index 2d7383dbf3..dd85f933c8 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java @@ -28,6 +28,8 @@ import com.google.android.exoplayer.hls.HlsMasterPlaylist; import com.google.android.exoplayer.hls.HlsMasterPlaylist.Variant; import com.google.android.exoplayer.hls.HlsMasterPlaylistParser; import com.google.android.exoplayer.hls.HlsSampleSource; +import com.google.android.exoplayer.metadata.Id3Parser; +import com.google.android.exoplayer.metadata.MetadataTrackRenderer; import com.google.android.exoplayer.upstream.BufferPool; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.UriDataSource; @@ -97,9 +99,13 @@ public class HlsRendererBuilder implements RendererBuilder, ManifestCallback parse(byte[] data, int size) + throws UnsupportedEncodingException, ParserException { + BitsArray id3Buffer = new BitsArray(data, size); + int id3Size = parseId3Header(id3Buffer); + + List metadata = new ArrayList(); + + while (id3Size > 0) { + int frameId0 = id3Buffer.readUnsignedByte(); + int frameId1 = id3Buffer.readUnsignedByte(); + int frameId2 = id3Buffer.readUnsignedByte(); + int frameId3 = id3Buffer.readUnsignedByte(); + + int frameSize = id3Buffer.readSynchSafeInt(); + if (frameSize <= 1) { + break; + } + + id3Buffer.skipBytes(2); // Skip frame flags. + + // Check Frame ID == TXXX. + if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && frameId3 == 'X') { + int encoding = id3Buffer.readUnsignedByte(); + String charset = getCharsetName(encoding); + byte[] frame = new byte[frameSize - 1]; + id3Buffer.readBytes(frame, 0, frameSize - 1); + + int firstZeroIndex = indexOf(frame, 0, (byte) 0); + String key = new String(frame, 0, firstZeroIndex, charset); + int valueStartIndex = indexOfNot(frame, firstZeroIndex, (byte) 0); + int valueEndIndex = indexOf(frame, valueStartIndex, (byte) 0); + String value = new String(frame, valueStartIndex, valueEndIndex - valueStartIndex, + charset); + metadata.add(new Metadata(key, value)); + } else { + id3Buffer.skipBytes(frameSize); + } + + id3Size -= frameSize + 10 /* header size */; + } + + return Collections.unmodifiableList(metadata); + } + + private static int indexOf(byte[] data, int fromIndex, byte key) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] == key) { + return i; + } + } + return data.length; + } + + private static int indexOfNot(byte[] data, int fromIndex, byte key) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] != key) { + return i; + } + } + return data.length; + } + + /** + * Parses ID3 header. + * @param id3Buffer A {@link BitsArray} with raw ID3 data. + * @return The size of data that contains ID3 frames without header and footer. + * @throws ParserException If ID3 file identifier != "ID3". + */ + private static int parseId3Header(BitsArray id3Buffer) throws ParserException { + int id1 = id3Buffer.readUnsignedByte(); + int id2 = id3Buffer.readUnsignedByte(); + int id3 = id3Buffer.readUnsignedByte(); + if (id1 != 'I' || id2 != 'D' || id3 != '3') { + throw new ParserException(String.format( + "Unexpected ID3 file identifier, expected \"ID3\", actual \"%c%c%c\".", id1, id2, id3)); + } + id3Buffer.skipBytes(2); // Skip version. + + int flags = id3Buffer.readUnsignedByte(); + int id3Size = id3Buffer.readSynchSafeInt(); + + // Check if extended header presents. + if ((flags & 0x2) != 0) { + int extendedHeaderSize = id3Buffer.readSynchSafeInt(); + if (extendedHeaderSize > 4) { + id3Buffer.skipBytes(extendedHeaderSize - 4); + } + id3Size -= extendedHeaderSize; + } + + // Check if footer presents. + if ((flags & 0x8) != 0) { + id3Size -= 10; + } + + return id3Size; + } + + /** + * Maps encoding byte from ID3v2 frame to a Charset. + * @param encodingByte The value of encoding byte from ID3v2 frame. + * @return Charset name. + */ + private static String getCharsetName(int encodingByte) { + switch (encodingByte) { + case 0: + return "ISO-8859-1"; + case 1: + return "UTF-16"; + case 2: + return "UTF-16BE"; + case 3: + return "UTF-8"; + default: + return "ISO-8859-1"; + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/Metadata.java b/library/src/main/java/com/google/android/exoplayer/metadata/Metadata.java new file mode 100644 index 0000000000..d89b02f9bd --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/Metadata.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +/** + * A metadata that contains textual data associated with time indices. + */ +public class Metadata { + + public final String key; + public final String value; + + public Metadata(String key, String value) { + this.key = key; + this.value = value; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java new file mode 100644 index 0000000000..46d2b1179a --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +import java.io.IOException; +import java.util.List; + +/** + * Parses {@link Metadata}s from binary data. + */ +public interface MetadataParser { + + /** + * Checks whether the parser supports a given mime type. + * + * @param mimeType A subtitle mime type. + * @return Whether the mime type is supported. + */ + public boolean canParse(String mimeType); + + /** + * Parses a list of {@link Metadata} objects from the provided binary data. + * + * @param data The raw binary data from which to parse the metadata. + * @param size The size of the input data. + * @return A parsed {@link List} of {@link Metadata} objects. + * @throws IOException If a problem occurred parsing the data. + */ + public List parse(byte[] data, int size) + throws IOException; + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java new file mode 100644 index 0000000000..5d5ad0c50d --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.util.Assertions; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; + +import java.io.IOException; +import java.util.List; + +/** + * A {@link TrackRenderer} for metadata embedded in a media stream. + */ +public class MetadataTrackRenderer extends TrackRenderer implements Callback { + + /** + * An interface for components that process metadata. + */ + public interface MetadataRenderer { + + /** + * Invoked each time there is a metadata associated with current playback time. + * + * @param metadata The metadata to process. + */ + void onMetadata(List metadata); + + } + + private static final int MSG_INVOKE_RENDERER = 0; + + private final SampleSource source; + private final MetadataParser metadataParser; + private final MetadataRenderer metadataRenderer; + private final Handler metadataHandler; + private final MediaFormatHolder formatHolder; + private final SampleHolder sampleHolder; + + private int trackIndex; + private long currentPositionUs; + private boolean inputStreamEnded; + + private long pendingMetadataTimestamp; + private List pendingMetadata; + + /** + * @param source A source from which samples containing metadata can be read. + * @param metadataParser A parser for parsing the metadata. + * @param metadataRenderer The metadata renderer to receive the parsed metadata. + * @param metadataRendererLooper The looper associated with the thread on which metadataRenderer + * should be invoked. If the renderer makes use of standard Android UI components, then this + * should normally be the looper associated with the applications' main thread, which can be + * obtained using {@link android.app.Activity#getMainLooper()}. Null may be passed if the + * renderer should be invoked directly on the player's internal rendering thread. + */ + public MetadataTrackRenderer(SampleSource source, MetadataParser metadataParser, + MetadataRenderer metadataRenderer, Looper metadataRendererLooper) { + this.source = Assertions.checkNotNull(source); + this.metadataParser = Assertions.checkNotNull(metadataParser); + this.metadataRenderer = Assertions.checkNotNull(metadataRenderer); + this.metadataHandler = metadataRendererLooper == null ? null + : new Handler(metadataRendererLooper, this); + formatHolder = new MediaFormatHolder(); + sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + } + + @Override + protected int doPrepare() throws ExoPlaybackException { + try { + boolean sourcePrepared = source.prepare(); + if (!sourcePrepared) { + return TrackRenderer.STATE_UNPREPARED; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + for (int i = 0; i < source.getTrackCount(); i++) { + if (metadataParser.canParse(source.getTrackInfo(i).mimeType)) { + trackIndex = i; + return TrackRenderer.STATE_PREPARED; + } + } + return TrackRenderer.STATE_IGNORE; + } + + @Override + protected void onEnabled(long positionUs, boolean joining) { + source.enable(trackIndex, positionUs); + seekToInternal(positionUs); + } + + @Override + protected void seekTo(long positionUs) throws ExoPlaybackException { + source.seekToUs(positionUs); + seekToInternal(positionUs); + } + + private void seekToInternal(long positionUs) { + currentPositionUs = positionUs; + pendingMetadata = null; + inputStreamEnded = false; + } + + @Override + protected void doSomeWork(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + currentPositionUs = positionUs; + try { + source.continueBuffering(positionUs); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + + if (!inputStreamEnded && pendingMetadata == null) { + try { + int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false); + if (result == SampleSource.SAMPLE_READ) { + pendingMetadataTimestamp = sampleHolder.timeUs; + pendingMetadata = metadataParser.parse(sampleHolder.data.array(), sampleHolder.size); + sampleHolder.data.clear(); + } else if (result == SampleSource.END_OF_STREAM) { + inputStreamEnded = true; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + if (pendingMetadata != null && pendingMetadataTimestamp <= currentPositionUs) { + invokeRenderer(pendingMetadata); + pendingMetadata = null; + } + } + + @Override + protected void onDisabled() { + pendingMetadata = null; + source.disable(trackIndex); + } + + @Override + protected long getDurationUs() { + return source.getTrackInfo(trackIndex).durationUs; + } + + @Override + protected long getCurrentPositionUs() { + return currentPositionUs; + } + + @Override + protected long getBufferedPositionUs() { + return TrackRenderer.END_OF_TRACK_US; + } + + @Override + protected boolean isEnded() { + return inputStreamEnded; + } + + @Override + protected boolean isReady() { + return true; + } + + private void invokeRenderer(List metadata) { + if (metadataHandler != null) { + metadataHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); + } else { + invokeRendererInternal(metadata); + } + } + + @SuppressWarnings("unchecked") + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INVOKE_RENDERER: + invokeRendererInternal((List) msg.obj); + return true; + } + return false; + } + + private void invokeRendererInternal(List metadata) { + metadataRenderer.onMetadata(metadata); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java b/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java index 6e51eceac3..862305f4b3 100644 --- a/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java +++ b/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java @@ -33,6 +33,14 @@ public final class BitsArray { private int byteOffset; private int bitOffset; + public BitsArray() { + } + + public BitsArray(byte[] data, int limit) { + this.data = data; + this.limit = limit; + } + /** * Resets the state. */ @@ -240,6 +248,22 @@ public final class BitsArray { return limit == 0; } + /** + * Reads a Synchsafe integer. + * Synchsafe integers are integers that keep the highest bit of every byte zeroed. + * A 32 bit synchsafe integer can store 28 bits of information. + * + * @return The value of the parsed Synchsafe integer. + */ + public int readSynchSafeInt() { + int b1 = readUnsignedByte(); + int b2 = readUnsignedByte(); + int b3 = readUnsignedByte(); + int b4 = readUnsignedByte(); + + return (b1 << 21) | (b2 << 14) | (b3 << 7) | b4; + } + // TODO: Find a better place for this method. /** * Finds the next Adts sync word. diff --git a/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java index fd5ba369b9..8b4099ef77 100644 --- a/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java @@ -58,6 +58,7 @@ public final class TsExtractor { private static final int TS_STREAM_TYPE_AAC = 0x0F; private static final int TS_STREAM_TYPE_H264 = 0x1B; + private static final int TS_STREAM_TYPE_ID3 = 0x15; private static final int DEFAULT_BUFFER_SEGMENT_SIZE = 64 * 1024; @@ -345,6 +346,9 @@ public final class TsExtractor { case TS_STREAM_TYPE_H264: pesPayloadReader = new H264Reader(); break; + case TS_STREAM_TYPE_ID3: + pesPayloadReader = new Id3Reader(); + break; } if (pesPayloadReader != null) { @@ -689,8 +693,25 @@ public final class TsExtractor { } /** - * Simplified version of SampleHolder for internal buffering. + * Parses ID3 data and extracts individual text information frames. */ + private class Id3Reader extends PesPayloadReader { + + public Id3Reader() { + setMediaFormat(MediaFormat.createId3Format()); + } + + @SuppressLint("InlinedApi") + @Override + public void read(BitsArray pesBuffer, int pesPayloadSize, long pesTimeUs) { + addSample(pesBuffer, pesPayloadSize, pesTimeUs, MediaExtractor.SAMPLE_FLAG_SYNC); + } + + } + + /** + * Simplified version of SampleHolder for internal buffering. + */ private static class Sample { public byte[] data; diff --git a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java index e3443467f3..55a59d1867 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java +++ b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java @@ -37,6 +37,7 @@ public class MimeTypes { public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; + public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; private MimeTypes() {}