Merge pull request #4993 from saschpe:icy

PiperOrigin-RevId: 226031838
This commit is contained in:
Oliver Woodman 2018-12-18 19:46:54 +00:00
commit be2636c365
17 changed files with 984 additions and 67 deletions

View file

@ -7,7 +7,6 @@
* Support for playing spherical videos on Daydream.
* Improve decoder re-use between playbacks. TODO: Write and link a blog post
here ([#2826](https://github.com/google/ExoPlayer/issues/2826)).
* Use the true bitrate for constant-bitrate MP3 seeking.
* Track selection:
* Add options for controlling audio track selections to `DefaultTrackSelector`
([#3314](https://github.com/google/ExoPlayer/issues/3314)).
@ -36,8 +35,12 @@
* DownloadManager:
* Create only one task for all DownloadActions for the same content.
* Rename TaskState to DownloadState.
* MP3: Fix issue where streams would play twice on some Samsung devices
([#4519](https://github.com/google/ExoPlayer/issues/4519)).
* MP3:
* Use the true bitrate for constant-bitrate MP3 seeking.
* Fix issue where streams would play twice on some Samsung devices
([#4519](https://github.com/google/ExoPlayer/issues/4519)).
* Add support for SHOUTcast ICY metadata
([#3735](https://github.com/google/ExoPlayer/issues/3735)).
### 2.9.2 ###

View file

@ -20,6 +20,7 @@ import android.support.annotation.Nullable;
import android.text.TextUtils;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
@ -493,6 +494,11 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
if (dataSpec.httpBody != null && !isContentTypeHeaderSet) {
throw new IOException("HTTP request with non-empty body must set Content-Type");
}
if (dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA)) {
requestBuilder.addHeader(
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME,
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE);
}
// Set the Range header.
if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) {
StringBuilder rangeValue = new StringBuilder();

View file

@ -21,6 +21,7 @@ import android.net.Uri;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
@ -263,7 +264,6 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
private Request makeRequest(DataSpec dataSpec) throws HttpDataSourceException {
long position = dataSpec.position;
long length = dataSpec.length;
boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP);
HttpUrl url = HttpUrl.parse(dataSpec.uri.toString());
if (url == null) {
@ -293,10 +293,14 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
if (userAgent != null) {
builder.addHeader("User-Agent", userAgent);
}
if (!allowGzip) {
if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) {
builder.addHeader("Accept-Encoding", "identity");
}
if (dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA)) {
builder.addHeader(
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME,
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE);
}
RequestBody requestBody = null;
if (dataSpec.httpBody != null) {
requestBody = RequestBody.create(null, dataSpec.httpBody);

View file

@ -1274,6 +1274,37 @@ public final class Format implements Parcelable {
metadata);
}
public Format copyWithBitrate(int bitrate) {
return new Format(
id,
label,
containerMimeType,
sampleMimeType,
codecs,
bitrate,
maxInputSize,
width,
height,
frameRate,
rotationDegrees,
pixelWidthHeightRatio,
projectionData,
stereoMode,
colorInfo,
channelCount,
sampleRate,
pcmEncoding,
encoderDelay,
encoderPadding,
selectionFlags,
language,
accessibilityChannel,
subsampleOffsetUs,
initializationData,
drmInitData,
metadata);
}
/**
* Returns the number of pixels if this is a video format whose {@link #width} and {@link #height}
* are known, or {@link #NO_VALUE} otherwise

View file

@ -18,8 +18,10 @@ package com.google.android.exoplayer2.metadata;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.util.Util;
import java.util.Arrays;
import java.util.List;
import org.checkerframework.checker.nullness.compatqual.NullableType;
/**
* A collection of metadata entries.
@ -76,6 +78,18 @@ public final class Metadata implements Parcelable {
return entries[index];
}
/**
* Returns a copy of this metadata with the specified entries appended.
*
* @param entriesToAppend The entries to append.
* @return The metadata instance with the appended entries.
*/
public Metadata copyWithAppendedEntries(Entry... entriesToAppend) {
@NullableType Entry[] merged = Arrays.copyOf(entries, entries.length + entriesToAppend.length);
System.arraycopy(entriesToAppend, 0, merged, entries.length, entriesToAppend.length);
return new Metadata(Util.castNonNullTypeArray(merged));
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {

View file

@ -17,6 +17,7 @@ package com.google.android.exoplayer2.metadata;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder;
import com.google.android.exoplayer2.metadata.icy.IcyDecoder;
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
import com.google.android.exoplayer2.metadata.scte35.SpliceInfoDecoder;
import com.google.android.exoplayer2.util.MimeTypes;
@ -46,38 +47,43 @@ public interface MetadataDecoderFactory {
/**
* Default {@link MetadataDecoder} implementation.
* <p>
* The formats supported by this factory are:
*
* <p>The formats supported by this factory are:
*
* <ul>
* <li>ID3 ({@link Id3Decoder})</li>
* <li>EMSG ({@link EventMessageDecoder})</li>
* <li>SCTE-35 ({@link SpliceInfoDecoder})</li>
* <li>ID3 ({@link Id3Decoder})
* <li>EMSG ({@link EventMessageDecoder})
* <li>SCTE-35 ({@link SpliceInfoDecoder})
* <li>ICY ({@link IcyDecoder})
* </ul>
*/
MetadataDecoderFactory DEFAULT = new MetadataDecoderFactory() {
MetadataDecoderFactory DEFAULT =
new MetadataDecoderFactory() {
@Override
public boolean supportsFormat(Format format) {
String mimeType = format.sampleMimeType;
return MimeTypes.APPLICATION_ID3.equals(mimeType)
|| MimeTypes.APPLICATION_EMSG.equals(mimeType)
|| MimeTypes.APPLICATION_SCTE35.equals(mimeType);
}
@Override
public MetadataDecoder createDecoder(Format format) {
switch (format.sampleMimeType) {
case MimeTypes.APPLICATION_ID3:
return new Id3Decoder();
case MimeTypes.APPLICATION_EMSG:
return new EventMessageDecoder();
case MimeTypes.APPLICATION_SCTE35:
return new SpliceInfoDecoder();
default:
throw new IllegalArgumentException("Attempted to create decoder for unsupported format");
}
}
};
@Override
public boolean supportsFormat(Format format) {
String mimeType = format.sampleMimeType;
return MimeTypes.APPLICATION_ID3.equals(mimeType)
|| MimeTypes.APPLICATION_EMSG.equals(mimeType)
|| MimeTypes.APPLICATION_SCTE35.equals(mimeType)
|| MimeTypes.APPLICATION_ICY.equals(mimeType);
}
@Override
public MetadataDecoder createDecoder(Format format) {
switch (format.sampleMimeType) {
case MimeTypes.APPLICATION_ID3:
return new Id3Decoder();
case MimeTypes.APPLICATION_EMSG:
return new EventMessageDecoder();
case MimeTypes.APPLICATION_SCTE35:
return new SpliceInfoDecoder();
case MimeTypes.APPLICATION_ICY:
return new IcyDecoder();
default:
throw new IllegalArgumentException(
"Attempted to create decoder for unsupported format");
}
}
};
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (C) 2018 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.icy;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.MetadataDecoder;
import com.google.android.exoplayer2.metadata.MetadataInputBuffer;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.nio.ByteBuffer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Decodes ICY stream information. */
public final class IcyDecoder implements MetadataDecoder {
private static final String TAG = "IcyDecoder";
private static final Pattern METADATA_ELEMENT = Pattern.compile("(.+?)='(.+?)';");
private static final String STREAM_KEY_NAME = "streamtitle";
private static final String STREAM_KEY_URL = "streamurl";
@Override
@Nullable
@SuppressWarnings("ByteBufferBackingArray")
public Metadata decode(MetadataInputBuffer inputBuffer) {
ByteBuffer buffer = inputBuffer.data;
byte[] data = buffer.array();
int length = buffer.limit();
return decode(Util.fromUtf8Bytes(data, 0, length));
}
@Nullable
@VisibleForTesting
/* package */ Metadata decode(String metadata) {
String name = null;
String url = null;
int index = 0;
Matcher matcher = METADATA_ELEMENT.matcher(metadata);
while (matcher.find(index)) {
String key = Util.toLowerInvariant(matcher.group(1));
String value = matcher.group(2);
switch (key) {
case STREAM_KEY_NAME:
name = value;
break;
case STREAM_KEY_URL:
url = value;
break;
default:
Log.w(TAG, "Unrecognized ICY tag: " + name);
break;
}
index = matcher.end();
}
return (name != null || url != null) ? new Metadata(new IcyInfo(name, url)) : null;
}
}

View file

@ -0,0 +1,243 @@
/*
* Copyright (C) 2018 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.icy;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.util.List;
import java.util.Map;
/** ICY headers. */
public final class IcyHeaders implements Metadata.Entry {
public static final String REQUEST_HEADER_ENABLE_METADATA_NAME = "Icy-MetaData";
public static final String REQUEST_HEADER_ENABLE_METADATA_VALUE = "1";
private static final String TAG = "IcyHeaders";
private static final String RESPONSE_HEADER_BITRATE = "icy-br";
private static final String RESPONSE_HEADER_GENRE = "icy-genre";
private static final String RESPONSE_HEADER_NAME = "icy-name";
private static final String RESPONSE_HEADER_URL = "icy-url";
private static final String RESPONSE_HEADER_PUB = "icy-pub";
private static final String RESPONSE_HEADER_METADATA_INTERVAL = "icy-metaint";
/**
* Parses {@link IcyHeaders} from response headers.
*
* @param responseHeaders The response headers.
* @return The parsed {@link IcyHeaders}, or {@code null} if no ICY headers were present.
*/
@Nullable
public static IcyHeaders parse(Map<String, List<String>> responseHeaders) {
boolean icyHeadersPresent = false;
int bitrate = Format.NO_VALUE;
String genre = null;
String name = null;
String url = null;
boolean isPublic = false;
int metadataInterval = C.LENGTH_UNSET;
List<String> headers = responseHeaders.get(RESPONSE_HEADER_BITRATE);
if (headers != null) {
String bitrateHeader = headers.get(0);
try {
bitrate = Integer.parseInt(bitrateHeader) * 1000;
if (bitrate > 0) {
icyHeadersPresent = true;
} else {
Log.w(TAG, "Invalid bitrate: " + bitrateHeader);
bitrate = Format.NO_VALUE;
}
} catch (NumberFormatException e) {
Log.w(TAG, "Invalid bitrate header: " + bitrateHeader);
}
}
headers = responseHeaders.get(RESPONSE_HEADER_GENRE);
if (headers != null) {
genre = headers.get(0);
icyHeadersPresent = true;
}
headers = responseHeaders.get(RESPONSE_HEADER_NAME);
if (headers != null) {
name = headers.get(0);
icyHeadersPresent = true;
}
headers = responseHeaders.get(RESPONSE_HEADER_URL);
if (headers != null) {
url = headers.get(0);
icyHeadersPresent = true;
}
headers = responseHeaders.get(RESPONSE_HEADER_PUB);
if (headers != null) {
isPublic = headers.get(0).equals("1");
icyHeadersPresent = true;
}
headers = responseHeaders.get(RESPONSE_HEADER_METADATA_INTERVAL);
if (headers != null) {
String metadataIntervalHeader = headers.get(0);
try {
metadataInterval = Integer.parseInt(metadataIntervalHeader);
if (metadataInterval > 0) {
icyHeadersPresent = true;
} else {
Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader);
metadataInterval = C.LENGTH_UNSET;
}
} catch (NumberFormatException e) {
Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader);
}
}
return icyHeadersPresent
? new IcyHeaders(bitrate, genre, name, url, isPublic, metadataInterval)
: null;
}
/**
* Bitrate in bits per second ({@code (icy-br * 1000)}), or {@link Format#NO_VALUE} if the header
* was not present.
*/
public final int bitrate;
/** The genre ({@code icy-genre}). */
@Nullable public final String genre;
/** The stream name ({@code icy-name}). */
@Nullable public final String name;
/** The URL of the radio station ({@code icy-url}). */
@Nullable public final String url;
/**
* Whether the radio station is listed ({@code icy-pub}), or {@code false} if the header was not
* present.
*/
public final boolean isPublic;
/**
* The interval in bytes between metadata chunks ({@code icy-metaint}), or {@link C#LENGTH_UNSET}
* if the header was not present.
*/
public final int metadataInterval;
/**
* @param bitrate See {@link #bitrate}.
* @param genre See {@link #genre}.
* @param name See {@link #name See}.
* @param url See {@link #url}.
* @param isPublic See {@link #isPublic}.
* @param metadataInterval See {@link #metadataInterval}.
*/
public IcyHeaders(
int bitrate,
@Nullable String genre,
@Nullable String name,
@Nullable String url,
boolean isPublic,
int metadataInterval) {
Assertions.checkArgument(metadataInterval == C.LENGTH_UNSET || metadataInterval > 0);
this.bitrate = bitrate;
this.genre = genre;
this.name = name;
this.url = url;
this.isPublic = isPublic;
this.metadataInterval = metadataInterval;
}
/* package */ IcyHeaders(Parcel in) {
bitrate = in.readInt();
genre = in.readString();
name = in.readString();
url = in.readString();
isPublic = Util.readBoolean(in);
metadataInterval = in.readInt();
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
IcyHeaders other = (IcyHeaders) obj;
return bitrate == other.bitrate
&& Util.areEqual(genre, other.genre)
&& Util.areEqual(name, other.name)
&& Util.areEqual(url, other.url)
&& isPublic == other.isPublic
&& metadataInterval == other.metadataInterval;
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + bitrate;
result = 31 * result + (genre != null ? genre.hashCode() : 0);
result = 31 * result + (name != null ? name.hashCode() : 0);
result = 31 * result + (url != null ? url.hashCode() : 0);
result = 31 * result + (isPublic ? 1 : 0);
result = 31 * result + metadataInterval;
return result;
}
@Override
public String toString() {
return "IcyHeaders: name=\""
+ name
+ "\", genre=\""
+ genre
+ "\", bitrate="
+ bitrate
+ ", metadataInterval="
+ metadataInterval;
}
// Parcelable implementation.
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(bitrate);
dest.writeString(genre);
dest.writeString(name);
dest.writeString(url);
Util.writeBoolean(dest, isPublic);
dest.writeInt(metadataInterval);
}
@Override
public int describeContents() {
return 0;
}
public static final Parcelable.Creator<IcyHeaders> CREATOR =
new Parcelable.Creator<IcyHeaders>() {
@Override
public IcyHeaders createFromParcel(Parcel in) {
return new IcyHeaders(in);
}
@Override
public IcyHeaders[] newArray(int size) {
return new IcyHeaders[size];
}
};
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (C) 2018 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.icy;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.util.Util;
/** ICY in-stream information. */
public final class IcyInfo implements Metadata.Entry {
/** The stream title if present, or {@code null}. */
@Nullable public final String title;
/** The stream title if present, or {@code null}. */
@Nullable public final String url;
/**
* @param title See {@link #title}.
* @param url See {@link #url}.
*/
public IcyInfo(@Nullable String title, @Nullable String url) {
this.title = title;
this.url = url;
}
/* package */ IcyInfo(Parcel in) {
title = in.readString();
url = in.readString();
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
IcyInfo other = (IcyInfo) obj;
return Util.areEqual(title, other.title) && Util.areEqual(url, other.url);
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + (title != null ? title.hashCode() : 0);
result = 31 * result + (url != null ? url.hashCode() : 0);
return result;
}
@Override
public String toString() {
return "ICY: title=\"" + title + "\", url=\"" + url + "\"";
}
// Parcelable implementation.
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(title);
dest.writeString(url);
}
@Override
public int describeContents() {
return 0;
}
public static final Parcelable.Creator<IcyInfo> CREATOR =
new Parcelable.Creator<IcyInfo>() {
@Override
public IcyInfo createFromParcel(Parcel in) {
return new IcyInfo(in);
}
@Override
public IcyInfo[] newArray(int size) {
return new IcyInfo[size];
}
};
}

View file

@ -31,6 +31,8 @@ import com.google.android.exoplayer2.extractor.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap;
import com.google.android.exoplayer2.extractor.SeekMap.SeekPoints;
import com.google.android.exoplayer2.extractor.TrackOutput;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
import com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener;
import com.google.android.exoplayer2.trackselection.TrackSelection;
@ -45,6 +47,7 @@ import com.google.android.exoplayer2.upstream.StatsDataSource;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.ConditionVariable;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
import java.io.EOFException;
import java.io.IOException;
@ -79,13 +82,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
*/
private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10000;
private static final Format ICY_FORMAT =
Format.createSampleFormat("icy", MimeTypes.APPLICATION_ICY, Format.OFFSET_SAMPLE_RELATIVE);
private final Uri uri;
private final DataSource dataSource;
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
private final EventDispatcher eventDispatcher;
private final Listener listener;
private final Allocator allocator;
private final @Nullable String customCacheKey;
@Nullable private final String customCacheKey;
private final long continueLoadingCheckIntervalBytes;
private final Loader loader;
private final ExtractorHolder extractorHolder;
@ -94,14 +100,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private final Runnable onContinueLoadingRequestedRunnable;
private final Handler handler;
private @Nullable Callback callback;
private @Nullable SeekMap seekMap;
@Nullable private Callback callback;
@Nullable private SeekMap seekMap;
@Nullable private IcyHeaders icyHeaders;
private SampleQueue[] sampleQueues;
private int[] sampleQueueTrackIds;
private TrackId[] sampleQueueTrackIds;
private boolean sampleQueuesBuilt;
private boolean prepared;
private @Nullable PreparedState preparedState;
@Nullable private PreparedState preparedState;
private boolean haveAudioVideoTracks;
private int dataType;
@ -134,7 +141,10 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
* invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}.
*/
// maybeFinishPrepare is not posted to the handler until initialization completes.
@SuppressWarnings("nullness:methodref.receiver.bound.invalid")
@SuppressWarnings({
"nullness:argument.type.incompatible",
"nullness:methodref.receiver.bound.invalid"
})
public ExtractorMediaPeriod(
Uri uri,
DataSource dataSource,
@ -164,7 +174,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
}
};
handler = new Handler();
sampleQueueTrackIds = new int[0];
sampleQueueTrackIds = new TrackId[0];
sampleQueues = new SampleQueue[0];
pendingResetPositionUs = C.TIME_UNSET;
length = C.LENGTH_UNSET;
@ -599,20 +609,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
@Override
public TrackOutput track(int id, int type) {
int trackCount = sampleQueues.length;
for (int i = 0; i < trackCount; i++) {
if (sampleQueueTrackIds[i] == id) {
return sampleQueues[i];
}
}
SampleQueue trackOutput = new SampleQueue(allocator);
trackOutput.setUpstreamFormatChangeListener(this);
sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1);
sampleQueueTrackIds[trackCount] = id;
@NullableType SampleQueue[] sampleQueues = Arrays.copyOf(this.sampleQueues, trackCount + 1);
sampleQueues[trackCount] = trackOutput;
this.sampleQueues = Util.castNonNullTypeArray(sampleQueues);
return trackOutput;
return prepareTrackOutput(new TrackId(id, /* isIcyTrack= */ false));
}
@Override
@ -627,6 +624,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
handler.post(maybeFinishPrepareRunnable);
}
// Icy metadata. Called by the loading thread.
/* package */ TrackOutput icyTrack() {
return prepareTrackOutput(new TrackId(0, /* isIcyTrack= */ true));
}
// UpstreamFormatChangedListener implementation. Called by the loading thread.
@Override
@ -636,6 +639,25 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
// Internal methods.
private TrackOutput prepareTrackOutput(TrackId id) {
int trackCount = sampleQueues.length;
for (int i = 0; i < trackCount; i++) {
if (id.equals(sampleQueueTrackIds[i])) {
return sampleQueues[i];
}
}
SampleQueue trackOutput = new SampleQueue(allocator);
trackOutput.setUpstreamFormatChangeListener(this);
@NullableType
TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1);
sampleQueueTrackIds[trackCount] = id;
this.sampleQueueTrackIds = Util.castNonNullTypeArray(sampleQueueTrackIds);
@NullableType SampleQueue[] sampleQueues = Arrays.copyOf(this.sampleQueues, trackCount + 1);
sampleQueues[trackCount] = trackOutput;
this.sampleQueues = Util.castNonNullTypeArray(sampleQueues);
return trackOutput;
}
private void maybeFinishPrepare() {
SeekMap seekMap = this.seekMap;
if (released || prepared || !sampleQueuesBuilt || seekMap == null) {
@ -653,11 +675,28 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
durationUs = seekMap.getDurationUs();
for (int i = 0; i < trackCount; i++) {
Format trackFormat = sampleQueues[i].getUpstreamFormat();
trackArray[i] = new TrackGroup(trackFormat);
String mimeType = trackFormat.sampleMimeType;
boolean isAudioVideo = MimeTypes.isVideo(mimeType) || MimeTypes.isAudio(mimeType);
boolean isAudio = MimeTypes.isAudio(mimeType);
boolean isAudioVideo = isAudio || MimeTypes.isVideo(mimeType);
trackIsAudioVideoFlags[i] = isAudioVideo;
haveAudioVideoTracks |= isAudioVideo;
IcyHeaders icyHeaders = this.icyHeaders;
if (icyHeaders != null) {
if (isAudio || sampleQueueTrackIds[i].isIcyTrack) {
Metadata metadata = trackFormat.metadata;
trackFormat =
trackFormat.copyWithMetadata(
metadata == null
? new Metadata(icyHeaders)
: metadata.copyWithAppendedEntries(icyHeaders));
}
if (isAudio
&& trackFormat.bitrate == Format.NO_VALUE
&& icyHeaders.bitrate != Format.NO_VALUE) {
trackFormat = trackFormat.copyWithBitrate(icyHeaders.bitrate);
}
}
trackArray[i] = new TrackGroup(trackFormat);
}
dataType =
length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET
@ -834,7 +873,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
}
/** Loads the media stream and extracts sample data from it. */
/* package */ final class ExtractingLoadable implements Loadable {
/* package */ final class ExtractingLoadable implements Loadable, IcyDataSource.Listener {
private final Uri uri;
private final StatsDataSource dataSource;
@ -849,6 +888,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private long seekTimeUs;
private DataSpec dataSpec;
private long length;
@Nullable private TrackOutput icyTrackOutput;
private boolean seenIcyMetadata;
@SuppressWarnings("method.invocation.invalid")
public ExtractingLoadable(
@ -888,7 +929,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
length += position;
}
Uri uri = Assertions.checkNotNull(dataSource.getUri());
input = new DefaultExtractorInput(dataSource, position, length);
icyHeaders = IcyHeaders.parse(dataSource.getResponseHeaders());
DataSource extractorDataSource = dataSource;
if (icyHeaders != null && icyHeaders.metadataInterval != C.LENGTH_UNSET) {
extractorDataSource = new IcyDataSource(dataSource, icyHeaders.metadataInterval, this);
icyTrackOutput = icyTrack();
icyTrackOutput.format(ICY_FORMAT);
}
input = new DefaultExtractorInput(extractorDataSource, position, length);
Extractor extractor = extractorHolder.selectExtractor(input, extractorOutput, uri);
if (pendingExtractorSeek) {
extractor.seek(position, seekTimeUs);
@ -914,6 +962,22 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
}
}
// IcyDataSource.Listener
@Override
public void onIcyMetadata(ParsableByteArray metadata) {
// Always output the first ICY metadata at the start time. This helps minimize any delay
// between the start of playback and the first ICY metadata event.
long timeUs =
!seenIcyMetadata ? seekTimeUs : Math.max(getLargestQueuedTimestampUs(), seekTimeUs);
int length = metadata.bytesLeft();
TrackOutput icyTrackOutput = Assertions.checkNotNull(this.icyTrackOutput);
icyTrackOutput.sampleData(metadata, length);
icyTrackOutput.sampleMetadata(
timeUs, C.BUFFER_FLAG_KEY_FRAME, length, /* offset= */ 0, /* encryptionData= */ null);
seenIcyMetadata = true;
}
// Internal methods.
private DataSpec buildDataSpec(long position) {
@ -924,13 +988,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
position,
C.LENGTH_UNSET,
customCacheKey,
DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
DataSpec.FLAG_ALLOW_ICY_METADATA | DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
}
private void setLoadPosition(long position, long timeUs) {
positionHolder.position = position;
seekTimeUs = timeUs;
pendingExtractorSeek = true;
seenIcyMetadata = false;
}
}
@ -1014,4 +1079,33 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
this.trackNotifiedDownstreamFormats = new boolean[tracks.length];
}
}
/** Identifies a track. */
private static final class TrackId {
public final int id;
public final boolean isIcyTrack;
public TrackId(int id, boolean isIcyTrack) {
this.id = id;
this.isIcyTrack = isIcyTrack;
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
TrackId other = (TrackId) obj;
return id == other.id && isIcyTrack == other.isIcyTrack;
}
@Override
public int hashCode() {
return 31 * id + (isIcyTrack ? 1 : 0);
}
}
}

View file

@ -0,0 +1,149 @@
/*
* Copyright (C) 2018 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.source;
import android.net.Uri;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* Splits ICY stream metadata out from a stream.
*
* <p>Note: {@link #open(DataSpec)} and {@link #close()} are not supported. This implementation is
* intended to wrap upstream {@link DataSource} instances that are opened and closed directly.
*/
/* package */ final class IcyDataSource implements DataSource {
public interface Listener {
/**
* Called when ICY stream metadata has been split from the stream.
*
* @param metadata The stream metadata in binary form.
*/
void onIcyMetadata(ParsableByteArray metadata);
}
private final DataSource upstream;
private final int metadataIntervalBytes;
private final Listener listener;
private final byte[] metadataLengthByteHolder;
private int bytesUntilMetadata;
/**
* @param upstream The upstream {@link DataSource}.
* @param metadataIntervalBytes The interval between ICY stream metadata, in bytes.
* @param listener A listener to which stream metadata is delivered.
*/
public IcyDataSource(DataSource upstream, int metadataIntervalBytes, Listener listener) {
Assertions.checkArgument(metadataIntervalBytes > 0);
this.upstream = upstream;
this.metadataIntervalBytes = metadataIntervalBytes;
this.listener = listener;
metadataLengthByteHolder = new byte[1];
bytesUntilMetadata = metadataIntervalBytes;
}
@Override
public void addTransferListener(TransferListener transferListener) {
upstream.addTransferListener(transferListener);
}
@Override
public long open(DataSpec dataSpec) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
if (bytesUntilMetadata == 0) {
if (readMetadata()) {
bytesUntilMetadata = metadataIntervalBytes;
} else {
return C.RESULT_END_OF_INPUT;
}
}
int bytesRead = upstream.read(buffer, offset, Math.min(bytesUntilMetadata, readLength));
if (bytesRead != C.RESULT_END_OF_INPUT) {
bytesUntilMetadata -= bytesRead;
}
return bytesRead;
}
@Nullable
@Override
public Uri getUri() {
return upstream.getUri();
}
@Override
public Map<String, List<String>> getResponseHeaders() {
return upstream.getResponseHeaders();
}
@Override
public void close() throws IOException {
throw new UnsupportedOperationException();
}
/**
* Reads an ICY stream metadata block, passing it to {@link #listener} unless the block is empty.
*
* @return True if the block was extracted, including if it's length byte indicated a length of
* zero. False if the end of the stream was reached.
* @throws IOException If an error occurs reading from the wrapped {@link DataSource}.
*/
private boolean readMetadata() throws IOException {
int bytesRead = upstream.read(metadataLengthByteHolder, 0, 1);
if (bytesRead == C.RESULT_END_OF_INPUT) {
return false;
}
int metadataLength = (metadataLengthByteHolder[0] & 0xFF) << 4;
if (metadataLength == 0) {
return true;
}
int offset = 0;
int lengthRemaining = metadataLength;
byte[] metadata = new byte[metadataLength];
while (lengthRemaining > 0) {
bytesRead = upstream.read(metadata, offset, lengthRemaining);
if (bytesRead == C.RESULT_END_OF_INPUT) {
return false;
}
offset += bytesRead;
lengthRemaining -= bytesRead;
}
// Discard trailing zero bytes.
while (metadataLength > 0 && metadata[metadataLength - 1] == 0) {
metadataLength--;
}
if (metadataLength > 0) {
listener.onIcyMetadata(new ParsableByteArray(metadata, metadataLength));
}
return true;
}
}

View file

@ -31,14 +31,15 @@ import java.util.Arrays;
public final class DataSpec {
/**
* The flags that apply to any request for data. Possible flag values are {@link #FLAG_ALLOW_GZIP}
* and {@link #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}.
* The flags that apply to any request for data. Possible flag values are {@link
* #FLAG_ALLOW_GZIP}, {@link #FLAG_ALLOW_ICY_METADATA} and {@link
* #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef(
flag = true,
value = {FLAG_ALLOW_GZIP, FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN})
value = {FLAG_ALLOW_GZIP, FLAG_ALLOW_ICY_METADATA, FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN})
public @interface Flags {}
/**
* Allows an underlying network stack to request that the server use gzip compression.
@ -53,8 +54,11 @@ public final class DataSpec {
*/
public static final int FLAG_ALLOW_GZIP = 1;
/** Allows an underlying network stack to request that the stream contain ICY metadata. */
public static final int FLAG_ALLOW_ICY_METADATA = 1 << 1; // 2
/** Prevents caching if the length cannot be resolved when the {@link DataSource} is opened. */
public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 1; // 2
public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 2; // 4
/**
* The set of HTTP methods that are supported by ExoPlayer {@link HttpDataSource}s. One of {@link

View file

@ -19,6 +19,7 @@ import android.net.Uri;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
@ -429,12 +430,20 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
long position = dataSpec.position;
long length = dataSpec.length;
boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP);
boolean allowIcyMetadata = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA);
if (!allowCrossProtocolRedirects) {
// HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection
// automatically. This is the behavior we want, so use it.
return makeConnection(
url, httpMethod, httpBody, position, length, allowGzip, true /* followRedirects */);
url,
httpMethod,
httpBody,
position,
length,
allowGzip,
allowIcyMetadata,
/* followRedirects= */ true);
}
// We need to handle redirects ourselves to allow cross-protocol redirects.
@ -442,7 +451,14 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
while (redirectCount++ <= MAX_REDIRECTS) {
HttpURLConnection connection =
makeConnection(
url, httpMethod, httpBody, position, length, allowGzip, false /* followRedirects */);
url,
httpMethod,
httpBody,
position,
length,
allowGzip,
allowIcyMetadata,
/* followRedirects= */ false);
int responseCode = connection.getResponseCode();
String location = connection.getHeaderField("Location");
if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD)
@ -482,6 +498,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
* @param position The byte offset of the requested data.
* @param length The length of the requested data, or {@link C#LENGTH_UNSET}.
* @param allowGzip Whether to allow the use of gzip.
* @param allowIcyMetadata Whether to allow ICY metadata.
* @param followRedirects Whether to follow redirects.
*/
private HttpURLConnection makeConnection(
@ -491,6 +508,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
long position,
long length,
boolean allowGzip,
boolean allowIcyMetadata,
boolean followRedirects)
throws IOException {
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
@ -515,6 +533,11 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
if (!allowGzip) {
connection.setRequestProperty("Accept-Encoding", "identity");
}
if (allowIcyMetadata) {
connection.setRequestProperty(
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME,
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE);
}
connection.setInstanceFollowRedirects(followRedirects);
connection.setDoOutput(httpBody != null);
connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod));

View file

@ -92,6 +92,7 @@ public final class MimeTypes {
public static final String APPLICATION_EMSG = BASE_TYPE_APPLICATION + "/x-emsg";
public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs";
public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif";
public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy";
private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>();

View file

@ -0,0 +1,80 @@
/*
* Copyright (C) 2018 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.icy;
import static com.google.common.truth.Truth.assertThat;
import com.google.android.exoplayer2.metadata.Metadata;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
/** Test for {@link IcyDecoder}. */
@RunWith(RobolectricTestRunner.class)
public final class IcyDecoderTest {
@Test
public void decode() {
IcyDecoder decoder = new IcyDecoder();
Metadata metadata = decoder.decode("StreamTitle='test title';StreamURL='test_url';");
assertThat(metadata.length()).isEqualTo(1);
IcyInfo streamInfo = (IcyInfo) metadata.get(0);
assertThat(streamInfo.title).isEqualTo("test title");
assertThat(streamInfo.url).isEqualTo("test_url");
}
@Test
public void decode_titleOnly() {
IcyDecoder decoder = new IcyDecoder();
Metadata metadata = decoder.decode("StreamTitle='test title';");
assertThat(metadata.length()).isEqualTo(1);
IcyInfo streamInfo = (IcyInfo) metadata.get(0);
assertThat(streamInfo.title).isEqualTo("test title");
assertThat(streamInfo.url).isNull();
}
@Test
public void decode_semiColonInTitle() {
IcyDecoder decoder = new IcyDecoder();
Metadata metadata = decoder.decode("StreamTitle='test; title';StreamURL='test_url';");
assertThat(metadata.length()).isEqualTo(1);
IcyInfo streamInfo = (IcyInfo) metadata.get(0);
assertThat(streamInfo.title).isEqualTo("test; title");
assertThat(streamInfo.url).isEqualTo("test_url");
}
@Test
public void decode_quoteInTitle() {
IcyDecoder decoder = new IcyDecoder();
Metadata metadata = decoder.decode("StreamTitle='test' title';StreamURL='test_url';");
assertThat(metadata.length()).isEqualTo(1);
IcyInfo streamInfo = (IcyInfo) metadata.get(0);
assertThat(streamInfo.title).isEqualTo("test' title");
assertThat(streamInfo.url).isEqualTo("test_url");
}
@Test
public void decode_notIcy() {
IcyDecoder decoder = new IcyDecoder();
Metadata metadata = decoder.decode("NotIcyData");
assertThat(metadata).isNull();
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (C) 2018 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.icy;
import static com.google.common.truth.Truth.assertThat;
import android.os.Parcel;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
/** Test for {@link IcyHeaders}. */
@RunWith(RobolectricTestRunner.class)
public final class IcyHeadersTest {
@Test
public void parcelEquals() {
IcyHeaders icyHeaders =
new IcyHeaders(
/* bitrate= */ 1234,
"genre",
"name",
"url",
/* isPublic= */ true,
/* metadataInterval= */ 5678);
// Write to parcel.
Parcel parcel = Parcel.obtain();
icyHeaders.writeToParcel(parcel, 0);
// Create from parcel.
parcel.setDataPosition(0);
IcyHeaders fromParcelIcyHeaders = IcyHeaders.CREATOR.createFromParcel(parcel);
// Assert equals.
assertThat(fromParcelIcyHeaders).isEqualTo(icyHeaders);
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (C) 2018 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.icy;
import static com.google.common.truth.Truth.assertThat;
import android.os.Parcel;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
/** Test for {@link IcyInfo}. */
@RunWith(RobolectricTestRunner.class)
public final class IcyStreamInfoTest {
@Test
public void parcelEquals() {
IcyInfo streamInfo = new IcyInfo("name", "url");
// Write to parcel.
Parcel parcel = Parcel.obtain();
streamInfo.writeToParcel(parcel, 0);
// Create from parcel.
parcel.setDataPosition(0);
IcyInfo fromParcelStreamInfo = IcyInfo.CREATOR.createFromParcel(parcel);
// Assert equals.
assertThat(fromParcelStreamInfo).isEqualTo(streamInfo);
}
}