summaryrefslogtreecommitdiffstats
path: root/media
diff options
context:
space:
mode:
Diffstat (limited to 'media')
-rw-r--r--media/java/android/media/MediaTimeProvider.java90
-rw-r--r--media/java/android/media/SubtitleController.java312
-rw-r--r--media/java/android/media/SubtitleTrack.java648
-rw-r--r--media/java/android/media/WebVttRenderer.java1094
-rw-r--r--media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java51
5 files changed, 2188 insertions, 7 deletions
diff --git a/media/java/android/media/MediaTimeProvider.java b/media/java/android/media/MediaTimeProvider.java
new file mode 100644
index 0000000..fe37712
--- /dev/null
+++ b/media/java/android/media/MediaTimeProvider.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2013 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 android.media;
+
+/** @hide */
+public interface MediaTimeProvider {
+ // we do not allow negative media time
+ /**
+ * Presentation time value if no timed event notification is requested.
+ */
+ public final static long NO_TIME = -1;
+
+ /**
+ * Cancels all previous notification request from this listener if any. It
+ * registers the listener to get seek and stop notifications. If timeUs is
+ * not negative, it also registers the listener for a timed event
+ * notification when the presentation time reaches (becomes greater) than
+ * the value specified. This happens immediately if the current media time
+ * is larger than or equal to timeUs.
+ *
+ * @param timeUs presentation time to get timed event callback at (or
+ * {@link #NO_TIME})
+ */
+ public void notifyAt(long timeUs, OnMediaTimeListener listener);
+
+ /**
+ * Cancels all previous notification request from this listener if any. It
+ * registers the listener to get seek and stop notifications. If the media
+ * is stopped, the listener will immediately receive a stop notification.
+ * Otherwise, it will receive a timed event notificaton.
+ */
+ public void scheduleUpdate(OnMediaTimeListener listener);
+
+ /**
+ * Cancels all previous notification request from this listener if any.
+ */
+ public void cancelNotifications(OnMediaTimeListener listener);
+
+ /**
+ * Get the current presentation time.
+ *
+ * @param precise Whether getting a precise time is important. This is
+ * more costly.
+ * @param monotonic Whether returned time should be monotonic: that is,
+ * greater than or equal to the last returned time. Don't
+ * always set this to true. E.g. this has undesired
+ * consequences if the media is seeked between calls.
+ * @throws IllegalStateException if the media is not initialized
+ */
+ public long getCurrentTimeUs(boolean precise, boolean monotonic)
+ throws IllegalStateException;
+
+ /** @hide */
+ public static interface OnMediaTimeListener {
+ /**
+ * Called when the registered time was reached naturally.
+ *
+ * @param timeUs current media time
+ */
+ void onTimedEvent(long timeUs);
+
+ /**
+ * Called when the media time changed due to seeking.
+ *
+ * @param timeUs current media time
+ */
+ void onSeek(long timeUs);
+
+ /**
+ * Called when the playback stopped. This is not called on pause, only
+ * on full stop, at which point there is no further current media time.
+ */
+ void onStop();
+ }
+}
+
diff --git a/media/java/android/media/SubtitleController.java b/media/java/android/media/SubtitleController.java
new file mode 100644
index 0000000..2cf1b2d
--- /dev/null
+++ b/media/java/android/media/SubtitleController.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright (C) 2013 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 android.media;
+
+import java.util.Locale;
+import java.util.Vector;
+
+import android.content.Context;
+import android.media.MediaPlayer.OnSubtitleDataListener;
+import android.view.View;
+import android.view.accessibility.CaptioningManager;
+
+/**
+ * The subtitle controller provides the architecture to display subtitles for a
+ * media source. It allows specifying which tracks to display, on which anchor
+ * to display them, and also allows adding external, out-of-band subtitle tracks.
+ *
+ * @hide
+ */
+public class SubtitleController {
+ private Context mContext;
+ private MediaTimeProvider mTimeProvider;
+ private Vector<Renderer> mRenderers;
+ private Vector<SubtitleTrack> mTracks;
+ private SubtitleTrack mSelectedTrack;
+ private boolean mShowing;
+ private CaptioningManager mCaptioningManager;
+
+ /**
+ * Creates a subtitle controller for a media playback object that implements
+ * the MediaTimeProvider interface.
+ *
+ * @param timeProvider
+ */
+ public SubtitleController(
+ Context context,
+ MediaTimeProvider timeProvider,
+ Listener listener) {
+ mContext = context;
+ mTimeProvider = timeProvider;
+ mListener = listener;
+
+ mRenderers = new Vector<Renderer>();
+ mShowing = false;
+ mTracks = new Vector<SubtitleTrack>();
+ mCaptioningManager =
+ (CaptioningManager)context.getSystemService(Context.CAPTIONING_SERVICE);
+ }
+
+ /**
+ * @return the available subtitle tracks for this media. These include
+ * the tracks found by {@link MediaPlayer} as well as any tracks added
+ * manually via {@link #addTrack}.
+ */
+ public SubtitleTrack[] getTracks() {
+ SubtitleTrack[] tracks = new SubtitleTrack[mTracks.size()];
+ mTracks.toArray(tracks);
+ return tracks;
+ }
+
+ /**
+ * @return the currently selected subtitle track
+ */
+ public SubtitleTrack getSelectedTrack() {
+ return mSelectedTrack;
+ }
+
+ private View getSubtitleView() {
+ if (mSelectedTrack == null) {
+ return null;
+ }
+ return mSelectedTrack.getView();
+ }
+
+ /**
+ * Selects a subtitle track. As a result, this track will receive
+ * in-band data from the {@link MediaPlayer}. However, this does
+ * not change the subtitle visibility.
+ *
+ * @param track The subtitle track to select. This must be one of the
+ * tracks in {@link #getTracks}.
+ * @return true if the track was successfully selected.
+ */
+ public boolean selectTrack(SubtitleTrack track) {
+ if (track != null && !mTracks.contains(track)) {
+ return false;
+ }
+ mTrackIsExplicit = true;
+ if (mSelectedTrack == track) {
+ return true;
+ }
+
+ if (mSelectedTrack != null) {
+ mSelectedTrack.hide();
+ mSelectedTrack.setTimeProvider(null);
+ }
+
+ mSelectedTrack = track;
+ mAnchor.setSubtitleView(getSubtitleView());
+
+ if (mSelectedTrack != null) {
+ mSelectedTrack.setTimeProvider(mTimeProvider);
+ mSelectedTrack.show();
+ }
+
+ if (mListener != null) {
+ mListener.onSubtitleTrackSelected(track);
+ }
+ return true;
+ }
+
+ /**
+ * @return the default subtitle track based on system preferences, or null,
+ * if no such track exists in this manager.
+ */
+ public SubtitleTrack getDefaultTrack() {
+ Locale locale = mCaptioningManager.getLocale();
+
+ for (SubtitleTrack track: mTracks) {
+ MediaFormat format = track.getFormat();
+ String language = format.getString(MediaFormat.KEY_LANGUAGE);
+ // TODO: select track with best renderer. For now, we select first
+ // track with local's language or first track if locale has none
+ if (locale == null ||
+ locale.getLanguage().equals("") ||
+ locale.getISO3Language().equals(language) ||
+ locale.getLanguage().equals(language)) {
+ return track;
+ }
+ }
+ return null;
+ }
+
+ private boolean mTrackIsExplicit = false;
+ private boolean mVisibilityIsExplicit = false;
+
+ /** @hide */
+ public void selectDefaultTrack() {
+ if (mTrackIsExplicit) {
+ return;
+ }
+
+ SubtitleTrack track = getDefaultTrack();
+ if (track != null) {
+ selectTrack(track);
+ mTrackIsExplicit = false;
+ if (!mVisibilityIsExplicit) {
+ if (mCaptioningManager.isEnabled()) {
+ show();
+ } else {
+ hide();
+ }
+ mVisibilityIsExplicit = false;
+ }
+ }
+ }
+
+ /** @hide */
+ public void reset() {
+ hide();
+ selectTrack(null);
+ mTracks.clear();
+ mTrackIsExplicit = false;
+ mVisibilityIsExplicit = false;
+ }
+
+ /**
+ * Adds a new, external subtitle track to the manager.
+ *
+ * @param format the format of the track that will include at least
+ * the MIME type {@link MediaFormat@KEY_MIME}.
+ * @return the created {@link SubtitleTrack} object
+ */
+ public SubtitleTrack addTrack(MediaFormat format) {
+ for (Renderer renderer: mRenderers) {
+ if (renderer.supports(format)) {
+ SubtitleTrack track = renderer.createTrack(format);
+ if (track != null) {
+ mTracks.add(track);
+ return track;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Show the selected (or default) subtitle track.
+ */
+ public void show() {
+ mShowing = true;
+ mVisibilityIsExplicit = true;
+ if (mSelectedTrack != null) {
+ mSelectedTrack.show();
+ }
+ }
+
+ /**
+ * Hide the selected (or default) subtitle track.
+ */
+ public void hide() {
+ mVisibilityIsExplicit = true;
+ if (mSelectedTrack != null) {
+ mSelectedTrack.hide();
+ }
+ mShowing = false;
+ }
+
+ /**
+ * Interface for supporting a single or multiple subtitle types in {@link
+ * MediaPlayer}.
+ */
+ public abstract static class Renderer {
+ /**
+ * Called by {@link MediaPlayer}'s {@link SubtitleController} when a new
+ * subtitle track is detected, to see if it should use this object to
+ * parse and display this subtitle track.
+ *
+ * @param format the format of the track that will include at least
+ * the MIME type {@link MediaFormat@KEY_MIME}.
+ *
+ * @return true if and only if the track format is supported by this
+ * renderer
+ */
+ public abstract boolean supports(MediaFormat format);
+
+ /**
+ * Called by {@link MediaPlayer}'s {@link SubtitleController} for each
+ * subtitle track that was detected and is supported by this object to
+ * create a {@link SubtitleTrack} object. This object will be created
+ * for each track that was found. If the track is selected for display,
+ * this object will be used to parse and display the track data.
+ *
+ * @param format the format of the track that will include at least
+ * the MIME type {@link MediaFormat@KEY_MIME}.
+ * @return a {@link SubtitleTrack} object that will be used to parse
+ * and render the subtitle track.
+ */
+ public abstract SubtitleTrack createTrack(MediaFormat format);
+ }
+
+ /**
+ * Add support for a subtitle format in {@link MediaPlayer}.
+ *
+ * @param renderer a {@link SubtitleController.Renderer} object that adds
+ * support for a subtitle format.
+ */
+ public void registerRenderer(Renderer renderer) {
+ // TODO how to get available renderers in the system
+ if (!mRenderers.contains(renderer)) {
+ // TODO should added renderers override existing ones (to allow replacing?)
+ mRenderers.add(renderer);
+ }
+ }
+
+ /**
+ * Subtitle anchor, an object that is able to display a subtitle view,
+ * e.g. a VideoView.
+ */
+ public interface Anchor {
+ /**
+ * Anchor should set the subtitle view to the supplied view,
+ * or none, if the supplied view is null.
+ *
+ * @param view subtitle view, or null
+ */
+ public void setSubtitleView(View view);
+ }
+
+ private Anchor mAnchor;
+
+ /** @hide */
+ public void setAnchor(Anchor anchor) {
+ if (mAnchor == anchor) {
+ return;
+ }
+
+ if (mAnchor != null) {
+ mAnchor.setSubtitleView(null);
+ }
+ mAnchor = anchor;
+ if (mAnchor != null) {
+ mAnchor.setSubtitleView(getSubtitleView());
+ }
+ }
+
+ public interface Listener {
+ /**
+ * Called when a subtitle track has been selected.
+ *
+ * @param track selected subtitle track or null
+ * @hide
+ */
+ public void onSubtitleTrackSelected(SubtitleTrack track);
+ }
+
+ private Listener mListener;
+}
diff --git a/media/java/android/media/SubtitleTrack.java b/media/java/android/media/SubtitleTrack.java
new file mode 100644
index 0000000..09fb3f2
--- /dev/null
+++ b/media/java/android/media/SubtitleTrack.java
@@ -0,0 +1,648 @@
+/*
+ * Copyright (C) 2013 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 android.media;
+
+import android.os.Handler;
+import android.util.Log;
+import android.util.LongSparseArray;
+import android.util.Pair;
+import android.view.View;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.Vector;
+
+/**
+ * A subtitle track abstract base class that is responsible for parsing and displaying
+ * an instance of a particular type of subtitle.
+ *
+ * @hide
+ */
+public abstract class SubtitleTrack implements MediaTimeProvider.OnMediaTimeListener {
+ private static final String TAG = "SubtitleTrack";
+ private long mLastUpdateTimeMs;
+ private long mLastTimeMs;
+
+ private Runnable mRunnable;
+
+ /** @hide TODO private */
+ final protected LongSparseArray<Run> mRunsByEndTime = new LongSparseArray<Run>();
+ /** @hide TODO private */
+ final protected LongSparseArray<Run> mRunsByID = new LongSparseArray<Run>();
+
+ /** @hide TODO private */
+ protected CueList mCues;
+ /** @hide TODO private */
+ final protected Vector<Cue> mActiveCues = new Vector<Cue>();
+ /** @hide */
+ protected boolean mVisible;
+
+ /** @hide */
+ public boolean DEBUG = false;
+
+ /** @hide */
+ protected Handler mHandler = new Handler();
+
+ private MediaFormat mFormat;
+
+ public SubtitleTrack(MediaFormat format) {
+ mFormat = format;
+ mCues = new CueList();
+ clearActiveCues();
+ mLastTimeMs = -1;
+ }
+
+ /** @hide */
+ public MediaFormat getFormat() {
+ return mFormat;
+ }
+
+ private long mNextScheduledTimeMs = -1;
+
+ /**
+ * Called when there is input data for the subtitle track. The
+ * complete subtitle for a track can include multiple whole units
+ * (runs). Each of these units can have multiple sections. The
+ * contents of a run are submitted in sequential order, with eos
+ * indicating the last section of the run. Calls from different
+ * runs must not be intermixed.
+ *
+ * @param data
+ * @param eos true if this is the last section of the run.
+ * @param runID mostly-unique ID for this run of data. Subtitle cues
+ * with runID of 0 are discarded immediately after
+ * display. Cues with runID of ~0 are discarded
+ * only at the deletion of the track object. Cues
+ * with other runID-s are discarded at the end of the
+ * run, which defaults to the latest timestamp of
+ * any of its cues (with this runID).
+ *
+ * TODO use ByteBuffer
+ */
+ public abstract void onData(String data, boolean eos, long runID);
+
+ /**
+ * Called when adding the subtitle rendering view to the view hierarchy, as
+ * well as when showing or hiding the subtitle track, or when the video
+ * surface position has changed.
+ *
+ * @return the view object that displays this subtitle track. For most
+ * renderers there should be a single shared view instance that is used
+ * for all tracks supported by that renderer, as at most one subtitle
+ * track is visible at one time.
+ */
+ public abstract View getView();
+
+ /**
+ * Called when the active cues have changed, and the contents of the subtitle
+ * view should be updated.
+ *
+ * @hide
+ */
+ public abstract void updateView(Vector<Cue> activeCues);
+
+ /** @hide */
+ protected synchronized void updateActiveCues(boolean rebuild, long timeMs) {
+ // out-of-order times mean seeking or new active cues being added
+ // (during their own timespan)
+ if (rebuild || mLastUpdateTimeMs > timeMs) {
+ clearActiveCues();
+ }
+
+ for(Iterator<Pair<Long, Cue> > it =
+ mCues.entriesBetween(mLastUpdateTimeMs, timeMs).iterator(); it.hasNext(); ) {
+ Pair<Long, Cue> event = it.next();
+ Cue cue = event.second;
+
+ if (cue.mEndTimeMs == event.first) {
+ // remove past cues
+ if (DEBUG) Log.v(TAG, "Removing " + cue);
+ mActiveCues.remove(cue);
+ if (cue.mRunID == 0) {
+ it.remove();
+ }
+ } else if (cue.mStartTimeMs == event.first) {
+ // add new cues
+ // TRICKY: this will happen in start order
+ if (DEBUG) Log.v(TAG, "Adding " + cue);
+ if (cue.mInnerTimesMs != null) {
+ cue.onTime(timeMs);
+ }
+ mActiveCues.add(cue);
+ } else if (cue.mInnerTimesMs != null) {
+ // cue is modified
+ cue.onTime(timeMs);
+ }
+ }
+
+ /* complete any runs */
+ while (mRunsByEndTime.size() > 0 &&
+ mRunsByEndTime.keyAt(0) <= timeMs) {
+ removeRunsByEndTimeIndex(0); // removes element
+ }
+ mLastUpdateTimeMs = timeMs;
+ }
+
+ private void removeRunsByEndTimeIndex(int ix) {
+ Run run = mRunsByEndTime.valueAt(ix);
+ while (run != null) {
+ Cue cue = run.mFirstCue;
+ while (cue != null) {
+ mCues.remove(cue);
+ Cue nextCue = cue.mNextInRun;
+ cue.mNextInRun = null;
+ cue = nextCue;
+ }
+ mRunsByID.remove(run.mRunID);
+ Run nextRun = run.mNextRunAtEndTimeMs;
+ run.mPrevRunAtEndTimeMs = null;
+ run.mNextRunAtEndTimeMs = null;
+ run = nextRun;
+ }
+ mRunsByEndTime.removeAt(ix);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ /* remove all cues (untangle all cross-links) */
+ int size = mRunsByEndTime.size();
+ for(int ix = size - 1; ix >= 0; ix--) {
+ removeRunsByEndTimeIndex(ix);
+ }
+
+ super.finalize();
+ }
+
+ private synchronized void takeTime(long timeMs) {
+ mLastTimeMs = timeMs;
+ }
+
+ /** @hide */
+ protected synchronized void clearActiveCues() {
+ if (DEBUG) Log.v(TAG, "Clearing " + mActiveCues.size() + " active cues");
+ mActiveCues.clear();
+ mLastUpdateTimeMs = -1;
+ }
+
+ /** @hide */
+ public void scheduleTimedEvents() {
+ /* get times for the next event */
+ if (mTimeProvider != null) {
+ mNextScheduledTimeMs = mCues.nextTimeAfter(mLastTimeMs);
+ if (DEBUG) Log.d(TAG, "sched @" + mNextScheduledTimeMs + " after " + mLastTimeMs);
+ mTimeProvider.notifyAt(
+ mNextScheduledTimeMs >= 0 ?
+ (mNextScheduledTimeMs * 1000) : MediaTimeProvider.NO_TIME,
+ this);
+ }
+ }
+
+ /**
+ * @hide
+ */
+ @Override
+ public void onTimedEvent(long timeUs) {
+ if (DEBUG) Log.d(TAG, "onTimedEvent " + timeUs);
+ synchronized (this) {
+ long timeMs = timeUs / 1000;
+ updateActiveCues(false, timeMs);
+ takeTime(timeMs);
+ }
+ updateView(mActiveCues);
+ scheduleTimedEvents();
+ }
+
+ /**
+ * @hide
+ */
+ @Override
+ public void onSeek(long timeUs) {
+ if (DEBUG) Log.d(TAG, "onSeek " + timeUs);
+ synchronized (this) {
+ long timeMs = timeUs / 1000;
+ updateActiveCues(true, timeMs);
+ takeTime(timeMs);
+ }
+ updateView(mActiveCues);
+ scheduleTimedEvents();
+ }
+
+ /**
+ * @hide
+ */
+ @Override
+ public void onStop() {
+ synchronized (this) {
+ if (DEBUG) Log.d(TAG, "onStop");
+ clearActiveCues();
+ mLastTimeMs = -1;
+ }
+ updateView(mActiveCues);
+ mNextScheduledTimeMs = -1;
+ mTimeProvider.notifyAt(MediaTimeProvider.NO_TIME, this);
+ }
+
+ /** @hide */
+ protected MediaTimeProvider mTimeProvider;
+
+ /** @hide */
+ public void show() {
+ if (mVisible) {
+ return;
+ }
+
+ mVisible = true;
+ getView().setVisibility(View.VISIBLE);
+ if (mTimeProvider != null) {
+ mTimeProvider.scheduleUpdate(this);
+ }
+ }
+
+ /** @hide */
+ public void hide() {
+ if (!mVisible) {
+ return;
+ }
+
+ if (mTimeProvider != null) {
+ mTimeProvider.cancelNotifications(this);
+ }
+ getView().setVisibility(View.INVISIBLE);
+ mVisible = false;
+ }
+
+ /** @hide */
+ protected synchronized boolean addCue(Cue cue) {
+ mCues.add(cue);
+
+ if (cue.mRunID != 0) {
+ Run run = mRunsByID.get(cue.mRunID);
+ if (run == null) {
+ run = new Run();
+ mRunsByID.put(cue.mRunID, run);
+ run.mEndTimeMs = cue.mEndTimeMs;
+ } else if (run.mEndTimeMs < cue.mEndTimeMs) {
+ run.mEndTimeMs = cue.mEndTimeMs;
+ }
+
+ // link-up cues in the same run
+ cue.mNextInRun = run.mFirstCue;
+ run.mFirstCue = cue;
+ }
+
+ // if a cue is added that should be visible, need to refresh view
+ long nowMs = -1;
+ if (mTimeProvider != null) {
+ try {
+ nowMs = mTimeProvider.getCurrentTimeUs(
+ false /* precise */, true /* monotonic */) / 1000;
+ } catch (IllegalStateException e) {
+ // handle as it we are not playing
+ }
+ }
+
+ if (DEBUG) Log.v(TAG, "mVisible=" + mVisible + ", " +
+ cue.mStartTimeMs + " <= " + nowMs + ", " +
+ cue.mEndTimeMs + " >= " + mLastTimeMs);
+
+ if (mVisible &&
+ cue.mStartTimeMs <= nowMs &&
+ // we don't trust nowMs, so check any cue since last callback
+ cue.mEndTimeMs >= mLastTimeMs) {
+ if (mRunnable != null) {
+ mHandler.removeCallbacks(mRunnable);
+ }
+ final SubtitleTrack track = this;
+ final long thenMs = nowMs;
+ mRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // even with synchronized, it is possible that we are going
+ // to do multiple updates as the runnable could be already
+ // running.
+ synchronized (track) {
+ mRunnable = null;
+ updateActiveCues(true, thenMs);
+ updateView(mActiveCues);
+ }
+ }
+ };
+ // delay update so we don't update view on every cue. TODO why 10?
+ if (mHandler.postDelayed(mRunnable, 10 /* delay */)) {
+ if (DEBUG) Log.v(TAG, "scheduling update");
+ } else {
+ if (DEBUG) Log.w(TAG, "failed to schedule subtitle view update");
+ }
+ return true;
+ }
+
+ if (mVisible &&
+ cue.mEndTimeMs >= mLastTimeMs &&
+ (cue.mStartTimeMs < mNextScheduledTimeMs ||
+ mNextScheduledTimeMs < 0)) {
+ scheduleTimedEvents();
+ }
+
+ return false;
+ }
+
+ /** @hide */
+ public void setTimeProvider(MediaTimeProvider timeProvider) {
+ if (mTimeProvider == timeProvider) {
+ return;
+ }
+ if (mTimeProvider != null) {
+ mTimeProvider.cancelNotifications(this);
+ }
+ mTimeProvider = timeProvider;
+ if (mTimeProvider != null) {
+ mTimeProvider.scheduleUpdate(this);
+ }
+ }
+
+
+ /** @hide */
+ static class CueList {
+ private static final String TAG = "CueList";
+ // simplistic, inefficient implementation
+ private SortedMap<Long, Vector<Cue> > mCues;
+ public boolean DEBUG = false;
+
+ private boolean addEvent(Cue cue, long timeMs) {
+ Vector<Cue> cues = mCues.get(timeMs);
+ if (cues == null) {
+ cues = new Vector<Cue>(2);
+ mCues.put(timeMs, cues);
+ } else if (cues.contains(cue)) {
+ // do not duplicate cues
+ return false;
+ }
+
+ cues.add(cue);
+ return true;
+ }
+
+ private void removeEvent(Cue cue, long timeMs) {
+ Vector<Cue> cues = mCues.get(timeMs);
+ if (cues != null) {
+ cues.remove(cue);
+ if (cues.size() == 0) {
+ mCues.remove(timeMs);
+ }
+ }
+ }
+
+ public void add(Cue cue) {
+ // ignore non-positive-duration cues
+ if (cue.mStartTimeMs >= cue.mEndTimeMs)
+ return;
+
+ if (!addEvent(cue, cue.mStartTimeMs)) {
+ return;
+ }
+
+ long lastTimeMs = cue.mStartTimeMs;
+ if (cue.mInnerTimesMs != null) {
+ for (long timeMs: cue.mInnerTimesMs) {
+ if (timeMs > lastTimeMs && timeMs < cue.mEndTimeMs) {
+ addEvent(cue, timeMs);
+ lastTimeMs = timeMs;
+ }
+ }
+ }
+
+ addEvent(cue, cue.mEndTimeMs);
+ }
+
+ public void remove(Cue cue) {
+ removeEvent(cue, cue.mStartTimeMs);
+ if (cue.mInnerTimesMs != null) {
+ for (long timeMs: cue.mInnerTimesMs) {
+ removeEvent(cue, timeMs);
+ }
+ }
+ removeEvent(cue, cue.mEndTimeMs);
+ }
+
+ public Iterable<Pair<Long, Cue>> entriesBetween(
+ final long lastTimeMs, final long timeMs) {
+ return new Iterable<Pair<Long, Cue> >() {
+ @Override
+ public Iterator<Pair<Long, Cue> > iterator() {
+ if (DEBUG) Log.d(TAG, "slice (" + lastTimeMs + ", " + timeMs + "]=");
+ try {
+ return new EntryIterator(
+ mCues.subMap(lastTimeMs + 1, timeMs + 1));
+ } catch(IllegalArgumentException e) {
+ return new EntryIterator(null);
+ }
+ }
+ };
+ }
+
+ public long nextTimeAfter(long timeMs) {
+ SortedMap<Long, Vector<Cue>> tail = null;
+ try {
+ tail = mCues.tailMap(timeMs + 1);
+ if (tail != null) {
+ return tail.firstKey();
+ } else {
+ return -1;
+ }
+ } catch(IllegalArgumentException e) {
+ return -1;
+ } catch(NoSuchElementException e) {
+ return -1;
+ }
+ }
+
+ class EntryIterator implements Iterator<Pair<Long, Cue> > {
+ @Override
+ public boolean hasNext() {
+ return !mDone;
+ }
+
+ @Override
+ public Pair<Long, Cue> next() {
+ if (mDone) {
+ throw new NoSuchElementException("");
+ }
+ mLastEntry = new Pair<Long, Cue>(
+ mCurrentTimeMs, mListIterator.next());
+ mLastListIterator = mListIterator;
+ if (!mListIterator.hasNext()) {
+ nextKey();
+ }
+ return mLastEntry;
+ }
+
+ @Override
+ public void remove() {
+ // only allow removing end tags
+ if (mLastListIterator == null ||
+ mLastEntry.second.mEndTimeMs != mLastEntry.first) {
+ throw new IllegalStateException("");
+ }
+
+ // remove end-cue
+ mLastListIterator.remove();
+ mLastListIterator = null;
+ if (mCues.get(mLastEntry.first).size() == 0) {
+ mCues.remove(mLastEntry.first);
+ }
+
+ // remove rest of the cues
+ Cue cue = mLastEntry.second;
+ removeEvent(cue, cue.mStartTimeMs);
+ if (cue.mInnerTimesMs != null) {
+ for (long timeMs: cue.mInnerTimesMs) {
+ removeEvent(cue, timeMs);
+ }
+ }
+ }
+
+ public EntryIterator(SortedMap<Long, Vector<Cue> > cues) {
+ if (DEBUG) Log.v(TAG, cues + "");
+ mRemainingCues = cues;
+ mLastListIterator = null;
+ nextKey();
+ }
+
+ private void nextKey() {
+ do {
+ try {
+ if (mRemainingCues == null) {
+ throw new NoSuchElementException("");
+ }
+ mCurrentTimeMs = mRemainingCues.firstKey();
+ mListIterator =
+ mRemainingCues.get(mCurrentTimeMs).iterator();
+ try {
+ mRemainingCues =
+ mRemainingCues.tailMap(mCurrentTimeMs + 1);
+ } catch (IllegalArgumentException e) {
+ mRemainingCues = null;
+ }
+ mDone = false;
+ } catch (NoSuchElementException e) {
+ mDone = true;
+ mRemainingCues = null;
+ mListIterator = null;
+ return;
+ }
+ } while (!mListIterator.hasNext());
+ }
+
+ private long mCurrentTimeMs;
+ private Iterator<Cue> mListIterator;
+ private boolean mDone;
+ private SortedMap<Long, Vector<Cue> > mRemainingCues;
+ private Iterator<Cue> mLastListIterator;
+ private Pair<Long,Cue> mLastEntry;
+ }
+
+ CueList() {
+ mCues = new TreeMap<Long, Vector<Cue>>();
+ }
+ }
+
+ /** @hide */
+ public static class Cue {
+ public long mStartTimeMs;
+ public long mEndTimeMs;
+ public long[] mInnerTimesMs;
+ public long mRunID;
+
+ /** @hide */
+ public Cue mNextInRun;
+
+ public void onTime(long timeMs) { }
+ }
+
+ /** @hide update mRunsByEndTime (with default end time) */
+ protected void finishedRun(long runID) {
+ if (runID != 0 && runID != ~0) {
+ Run run = mRunsByID.get(runID);
+ if (run != null) {
+ run.storeByEndTimeMs(mRunsByEndTime);
+ }
+ }
+ }
+
+ /** @hide update mRunsByEndTime with given end time */
+ public void setRunDiscardTimeMs(long runID, long timeMs) {
+ if (runID != 0 && runID != ~0) {
+ Run run = mRunsByID.get(runID);
+ if (run != null) {
+ run.mEndTimeMs = timeMs;
+ run.storeByEndTimeMs(mRunsByEndTime);
+ }
+ }
+ }
+
+ /** @hide */
+ private static class Run {
+ public Cue mFirstCue;
+ public Run mNextRunAtEndTimeMs;
+ public Run mPrevRunAtEndTimeMs;
+ public long mEndTimeMs = -1;
+ public long mRunID = 0;
+ private long mStoredEndTimeMs = -1;
+
+ public void storeByEndTimeMs(LongSparseArray<Run> runsByEndTime) {
+ // remove old value if any
+ int ix = runsByEndTime.indexOfKey(mStoredEndTimeMs);
+ if (ix >= 0) {
+ if (mPrevRunAtEndTimeMs == null) {
+ assert(this == runsByEndTime.valueAt(ix));
+ if (mNextRunAtEndTimeMs == null) {
+ runsByEndTime.removeAt(ix);
+ } else {
+ runsByEndTime.setValueAt(ix, mNextRunAtEndTimeMs);
+ }
+ }
+ removeAtEndTimeMs();
+ }
+
+ // add new value
+ if (mEndTimeMs >= 0) {
+ mPrevRunAtEndTimeMs = null;
+ mNextRunAtEndTimeMs = runsByEndTime.get(mEndTimeMs);
+ if (mNextRunAtEndTimeMs != null) {
+ mNextRunAtEndTimeMs.mPrevRunAtEndTimeMs = this;
+ }
+ runsByEndTime.put(mEndTimeMs, this);
+ mStoredEndTimeMs = mEndTimeMs;
+ }
+ }
+
+ public void removeAtEndTimeMs() {
+ Run prev = mPrevRunAtEndTimeMs;
+
+ if (mPrevRunAtEndTimeMs != null) {
+ mPrevRunAtEndTimeMs.mNextRunAtEndTimeMs = mNextRunAtEndTimeMs;
+ mPrevRunAtEndTimeMs = null;
+ }
+ if (mNextRunAtEndTimeMs != null) {
+ mNextRunAtEndTimeMs.mPrevRunAtEndTimeMs = prev;
+ mNextRunAtEndTimeMs = null;
+ }
+ }
+ }
+}
diff --git a/media/java/android/media/WebVttRenderer.java b/media/java/android/media/WebVttRenderer.java
new file mode 100644
index 0000000..527c57f
--- /dev/null
+++ b/media/java/android/media/WebVttRenderer.java
@@ -0,0 +1,1094 @@
+package android.media;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.TextView;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Vector;
+
+/** @hide */
+public class WebVttRenderer extends SubtitleController.Renderer {
+ private TextView mMyTextView;
+
+ public WebVttRenderer(Context context, AttributeSet attrs) {
+ mMyTextView = new WebVttView(context, attrs);
+ }
+
+ @Override
+ public boolean supports(MediaFormat format) {
+ if (format.containsKey(MediaFormat.KEY_MIME)) {
+ return format.getString(MediaFormat.KEY_MIME).equals("text/vtt");
+ }
+ return false;
+ }
+
+ @Override
+ public SubtitleTrack createTrack(MediaFormat format) {
+ return new WebVttTrack(format, mMyTextView);
+ }
+}
+
+/** @hide */
+class WebVttView extends TextView {
+ public WebVttView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setTextColor(0xffffff00);
+ setTextSize(46);
+ setTextAlignment(TextView.TEXT_ALIGNMENT_CENTER);
+ setLayoutParams(new LayoutParams(
+ LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
+ }
+}
+
+/** @hide */
+class TextTrackCueSpan {
+ long mTimestampMs;
+ boolean mEnabled;
+ String mText;
+ TextTrackCueSpan(String text, long timestamp) {
+ mTimestampMs = timestamp;
+ mText = text;
+ // spans with timestamp will be enabled by Cue.onTime
+ mEnabled = (mTimestampMs < 0);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof TextTrackCueSpan)) {
+ return false;
+ }
+ TextTrackCueSpan span = (TextTrackCueSpan) o;
+ return mTimestampMs == span.mTimestampMs &&
+ mText.equals(span.mText);
+ }
+}
+
+/**
+ * @hide
+ *
+ * Extract all text without style, but with timestamp spans.
+ */
+class UnstyledTextExtractor implements Tokenizer.OnTokenListener {
+ StringBuilder mLine = new StringBuilder();
+ Vector<TextTrackCueSpan[]> mLines = new Vector<TextTrackCueSpan[]>();
+ Vector<TextTrackCueSpan> mCurrentLine = new Vector<TextTrackCueSpan>();
+ long mLastTimestamp;
+
+ UnstyledTextExtractor() {
+ init();
+ }
+
+ private void init() {
+ mLine.delete(0, mLine.length());
+ mLines.clear();
+ mCurrentLine.clear();
+ mLastTimestamp = -1;
+ }
+
+ @Override
+ public void onData(String s) {
+ mLine.append(s);
+ }
+
+ @Override
+ public void onStart(String tag, String[] classes, String annotation) { }
+
+ @Override
+ public void onEnd(String tag) { }
+
+ @Override
+ public void onTimeStamp(long timestampMs) {
+ // finish any prior span
+ if (mLine.length() > 0 && timestampMs != mLastTimestamp) {
+ mCurrentLine.add(
+ new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
+ mLine.delete(0, mLine.length());
+ }
+ mLastTimestamp = timestampMs;
+ }
+
+ @Override
+ public void onLineEnd() {
+ // finish any pending span
+ if (mLine.length() > 0) {
+ mCurrentLine.add(
+ new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
+ mLine.delete(0, mLine.length());
+ }
+
+ TextTrackCueSpan[] spans = new TextTrackCueSpan[mCurrentLine.size()];
+ mCurrentLine.toArray(spans);
+ mCurrentLine.clear();
+ mLines.add(spans);
+ }
+
+ public TextTrackCueSpan[][] getText() {
+ // for politeness, finish last cue-line if it ends abruptly
+ if (mLine.length() > 0 || mCurrentLine.size() > 0) {
+ onLineEnd();
+ }
+ TextTrackCueSpan[][] lines = new TextTrackCueSpan[mLines.size()][];
+ mLines.toArray(lines);
+ init();
+ return lines;
+ }
+}
+
+/**
+ * @hide
+ *
+ * Tokenizer tokenizes the WebVTT Cue Text into tags and data
+ */
+class Tokenizer {
+ private static final String TAG = "Tokenizer";
+ private TokenizerPhase mPhase;
+ private TokenizerPhase mDataTokenizer;
+ private TokenizerPhase mTagTokenizer;
+
+ private OnTokenListener mListener;
+ private String mLine;
+ private int mHandledLen;
+
+ interface TokenizerPhase {
+ TokenizerPhase start();
+ void tokenize();
+ }
+
+ class DataTokenizer implements TokenizerPhase {
+ // includes both WebVTT data && escape state
+ private StringBuilder mData;
+
+ public TokenizerPhase start() {
+ mData = new StringBuilder();
+ return this;
+ }
+
+ private boolean replaceEscape(String escape, String replacement, int pos) {
+ if (mLine.startsWith(escape, pos)) {
+ mData.append(mLine.substring(mHandledLen, pos));
+ mData.append(replacement);
+ mHandledLen = pos + escape.length();
+ pos = mHandledLen - 1;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void tokenize() {
+ int end = mLine.length();
+ for (int pos = mHandledLen; pos < mLine.length(); pos++) {
+ if (mLine.charAt(pos) == '&') {
+ if (replaceEscape("&amp;", "&", pos) ||
+ replaceEscape("&lt;", "<", pos) ||
+ replaceEscape("&gt;", ">", pos) ||
+ replaceEscape("&lrm;", "\u200e", pos) ||
+ replaceEscape("&rlm;", "\u200f", pos) ||
+ replaceEscape("&nbsp;", "\u00a0", pos)) {
+ continue;
+ }
+ } else if (mLine.charAt(pos) == '<') {
+ end = pos;
+ mPhase = mTagTokenizer.start();
+ break;
+ }
+ }
+ mData.append(mLine.substring(mHandledLen, end));
+ // yield mData
+ mListener.onData(mData.toString());
+ mData.delete(0, mData.length());
+ mHandledLen = end;
+ }
+ }
+
+ class TagTokenizer implements TokenizerPhase {
+ private boolean mAtAnnotation;
+ private String mName, mAnnotation;
+
+ public TokenizerPhase start() {
+ mName = mAnnotation = "";
+ mAtAnnotation = false;
+ return this;
+ }
+
+ @Override
+ public void tokenize() {
+ if (!mAtAnnotation)
+ mHandledLen++;
+ if (mHandledLen < mLine.length()) {
+ String[] parts;
+ /**
+ * Collect annotations and end-tags to closing >. Collect tag
+ * name to closing bracket or next white-space.
+ */
+ if (mAtAnnotation || mLine.charAt(mHandledLen) == '/') {
+ parts = mLine.substring(mHandledLen).split(">");
+ } else {
+ parts = mLine.substring(mHandledLen).split("[\t\f >]");
+ }
+ String part = mLine.substring(
+ mHandledLen, mHandledLen + parts[0].length());
+ mHandledLen += parts[0].length();
+
+ if (mAtAnnotation) {
+ mAnnotation += " " + part;
+ } else {
+ mName = part;
+ }
+ }
+
+ mAtAnnotation = true;
+
+ if (mHandledLen < mLine.length() && mLine.charAt(mHandledLen) == '>') {
+ yield_tag();
+ mPhase = mDataTokenizer.start();
+ mHandledLen++;
+ }
+ }
+
+ private void yield_tag() {
+ if (mName.startsWith("/")) {
+ mListener.onEnd(mName.substring(1));
+ } else if (mName.length() > 0 && Character.isDigit(mName.charAt(0))) {
+ // timestamp
+ try {
+ long timestampMs = WebVttParser.parseTimestampMs(mName);
+ mListener.onTimeStamp(timestampMs);
+ } catch (NumberFormatException e) {
+ Log.d(TAG, "invalid timestamp tag: <" + mName + ">");
+ }
+ } else {
+ mAnnotation = mAnnotation.replaceAll("\\s+", " ");
+ if (mAnnotation.startsWith(" ")) {
+ mAnnotation = mAnnotation.substring(1);
+ }
+ if (mAnnotation.endsWith(" ")) {
+ mAnnotation = mAnnotation.substring(0, mAnnotation.length() - 1);
+ }
+
+ String[] classes = null;
+ int dotAt = mName.indexOf('.');
+ if (dotAt >= 0) {
+ classes = mName.substring(dotAt + 1).split("\\.");
+ mName = mName.substring(0, dotAt);
+ }
+ mListener.onStart(mName, classes, mAnnotation);
+ }
+ }
+ }
+
+ Tokenizer(OnTokenListener listener) {
+ mDataTokenizer = new DataTokenizer();
+ mTagTokenizer = new TagTokenizer();
+ reset();
+ mListener = listener;
+ }
+
+ void reset() {
+ mPhase = mDataTokenizer.start();
+ }
+
+ void tokenize(String s) {
+ mHandledLen = 0;
+ mLine = s;
+ while (mHandledLen < mLine.length()) {
+ mPhase.tokenize();
+ }
+ /* we are finished with a line unless we are in the middle of a tag */
+ if (!(mPhase instanceof TagTokenizer)) {
+ // yield END-OF-LINE
+ mListener.onLineEnd();
+ }
+ }
+
+ interface OnTokenListener {
+ void onData(String s);
+ void onStart(String tag, String[] classes, String annotation);
+ void onEnd(String tag);
+ void onTimeStamp(long timestampMs);
+ void onLineEnd();
+ }
+}
+
+/** @hide */
+class TextTrackRegion {
+ final static int SCROLL_VALUE_NONE = 300;
+ final static int SCROLL_VALUE_SCROLL_UP = 301;
+
+ String mId;
+ float mWidth;
+ int mLines;
+ float mAnchorPointX, mAnchorPointY;
+ float mViewportAnchorPointX, mViewportAnchorPointY;
+ int mScrollValue;
+
+ TextTrackRegion() {
+ mId = "";
+ mWidth = 100;
+ mLines = 3;
+ mAnchorPointX = mViewportAnchorPointX = 0.f;
+ mAnchorPointY = mViewportAnchorPointY = 100.f;
+ mScrollValue = SCROLL_VALUE_NONE;
+ }
+
+ public String toString() {
+ StringBuilder res = new StringBuilder(" {id:\"").append(mId)
+ .append("\", width:").append(mWidth)
+ .append(", lines:").append(mLines)
+ .append(", anchorPoint:(").append(mAnchorPointX)
+ .append(", ").append(mAnchorPointY)
+ .append("), viewportAnchorPoints:").append(mViewportAnchorPointX)
+ .append(", ").append(mViewportAnchorPointY)
+ .append("), scrollValue:")
+ .append(mScrollValue == SCROLL_VALUE_NONE ? "none" :
+ mScrollValue == SCROLL_VALUE_SCROLL_UP ? "scroll_up" :
+ "INVALID")
+ .append("}");
+ return res.toString();
+ }
+}
+
+/** @hide */
+class TextTrackCue extends SubtitleTrack.Cue {
+ final static int WRITING_DIRECTION_HORIZONTAL = 100;
+ final static int WRITING_DIRECTION_VERTICAL_RL = 101;
+ final static int WRITING_DIRECTION_VERTICAL_LR = 102;
+
+ final static int ALIGNMENT_MIDDLE = 200;
+ final static int ALIGNMENT_START = 201;
+ final static int ALIGNMENT_END = 202;
+ final static int ALIGNMENT_LEFT = 203;
+ final static int ALIGNMENT_RIGHT = 204;
+ private static final String TAG = "TTCue";
+
+ String mId;
+ boolean mPauseOnExit;
+ int mWritingDirection;
+ String mRegionId;
+ boolean mSnapToLines;
+ Integer mLinePosition; // null means AUTO
+ boolean mAutoLinePosition;
+ int mTextPosition;
+ int mSize;
+ int mAlignment;
+ // Vector<String> mText;
+ String[] mStrings;
+ TextTrackCueSpan[][] mLines;
+ TextTrackRegion mRegion;
+
+ TextTrackCue() {
+ mId = "";
+ mPauseOnExit = false;
+ mWritingDirection = WRITING_DIRECTION_HORIZONTAL;
+ mRegionId = "";
+ mSnapToLines = true;
+ mLinePosition = null /* AUTO */;
+ mTextPosition = 50;
+ mSize = 100;
+ mAlignment = ALIGNMENT_MIDDLE;
+ mLines = null;
+ mRegion = null;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof TextTrackCue)) {
+ return false;
+ }
+ if (this == o) {
+ return true;
+ }
+
+ try {
+ TextTrackCue cue = (TextTrackCue) o;
+ boolean res = mId.equals(cue.mId) &&
+ mPauseOnExit == cue.mPauseOnExit &&
+ mWritingDirection == cue.mWritingDirection &&
+ mRegionId.equals(cue.mRegionId) &&
+ mSnapToLines == cue.mSnapToLines &&
+ mAutoLinePosition == cue.mAutoLinePosition &&
+ (mAutoLinePosition || mLinePosition == cue.mLinePosition) &&
+ mTextPosition == cue.mTextPosition &&
+ mSize == cue.mSize &&
+ mAlignment == cue.mAlignment &&
+ mLines.length == cue.mLines.length;
+ if (res == true) {
+ for (int line = 0; line < mLines.length; line++) {
+ if (!Arrays.equals(mLines[line], cue.mLines[line])) {
+ return false;
+ }
+ }
+ }
+ return res;
+ } catch(IncompatibleClassChangeError e) {
+ return false;
+ }
+ }
+
+ public StringBuilder appendStringsToBuilder(StringBuilder builder) {
+ if (mStrings == null) {
+ builder.append("null");
+ } else {
+ builder.append("[");
+ boolean first = true;
+ for (String s: mStrings) {
+ if (!first) {
+ builder.append(", ");
+ }
+ if (s == null) {
+ builder.append("null");
+ } else {
+ builder.append("\"");
+ builder.append(s);
+ builder.append("\"");
+ }
+ first = false;
+ }
+ builder.append("]");
+ }
+ return builder;
+ }
+
+ public StringBuilder appendLinesToBuilder(StringBuilder builder) {
+ if (mLines == null) {
+ builder.append("null");
+ } else {
+ builder.append("[");
+ boolean first = true;
+ for (TextTrackCueSpan[] spans: mLines) {
+ if (!first) {
+ builder.append(", ");
+ }
+ if (spans == null) {
+ builder.append("null");
+ } else {
+ builder.append("\"");
+ boolean innerFirst = true;
+ long lastTimestamp = -1;
+ for (TextTrackCueSpan span: spans) {
+ if (!innerFirst) {
+ builder.append(" ");
+ }
+ if (span.mTimestampMs != lastTimestamp) {
+ builder.append("<")
+ .append(WebVttParser.timeToString(
+ span.mTimestampMs))
+ .append(">");
+ lastTimestamp = span.mTimestampMs;
+ }
+ builder.append(span.mText);
+ innerFirst = false;
+ }
+ builder.append("\"");
+ }
+ first = false;
+ }
+ builder.append("]");
+ }
+ return builder;
+ }
+
+ public String toString() {
+ StringBuilder res = new StringBuilder();
+
+ res.append(WebVttParser.timeToString(mStartTimeMs))
+ .append(" --> ").append(WebVttParser.timeToString(mEndTimeMs))
+ .append(" {id:\"").append(mId)
+ .append("\", pauseOnExit:").append(mPauseOnExit)
+ .append(", direction:")
+ .append(mWritingDirection == WRITING_DIRECTION_HORIZONTAL ? "horizontal" :
+ mWritingDirection == WRITING_DIRECTION_VERTICAL_LR ? "vertical_lr" :
+ mWritingDirection == WRITING_DIRECTION_VERTICAL_RL ? "vertical_rl" :
+ "INVALID")
+ .append(", regionId:\"").append(mRegionId)
+ .append("\", snapToLines:").append(mSnapToLines)
+ .append(", linePosition:").append(mAutoLinePosition ? "auto" :
+ mLinePosition)
+ .append(", textPosition:").append(mTextPosition)
+ .append(", size:").append(mSize)
+ .append(", alignment:")
+ .append(mAlignment == ALIGNMENT_END ? "end" :
+ mAlignment == ALIGNMENT_LEFT ? "left" :
+ mAlignment == ALIGNMENT_MIDDLE ? "middle" :
+ mAlignment == ALIGNMENT_RIGHT ? "right" :
+ mAlignment == ALIGNMENT_START ? "start" : "INVALID")
+ .append(", text:");
+ appendStringsToBuilder(res).append("}");
+ return res.toString();
+ }
+
+ @Override
+ public int hashCode() {
+ return toString().hashCode();
+ }
+
+ @Override
+ public void onTime(long timeMs) {
+ for (TextTrackCueSpan[] line: mLines) {
+ for (TextTrackCueSpan span: line) {
+ span.mEnabled = timeMs >= span.mTimestampMs;
+ }
+ }
+ }
+}
+
+/** @hide */
+class WebVttParser {
+ private static final String TAG = "WebVttParser";
+ private Phase mPhase;
+ private TextTrackCue mCue;
+ private Vector<String> mCueTexts;
+ private WebVttCueListener mListener;
+ private String mBuffer;
+
+ WebVttParser(WebVttCueListener listener) {
+ mPhase = mParseStart;
+ mBuffer = ""; /* mBuffer contains up to 1 incomplete line */
+ mListener = listener;
+ mCueTexts = new Vector<String>();
+ }
+
+ /* parsePercentageString */
+ public static float parseFloatPercentage(String s)
+ throws NumberFormatException {
+ if (!s.endsWith("%")) {
+ throw new NumberFormatException("does not end in %");
+ }
+ s = s.substring(0, s.length() - 1);
+ // parseFloat allows an exponent or a sign
+ if (s.matches(".*[^0-9.].*")) {
+ throw new NumberFormatException("contains an invalid character");
+ }
+
+ try {
+ float value = Float.parseFloat(s);
+ if (value < 0.0f || value > 100.0f) {
+ throw new NumberFormatException("is out of range");
+ }
+ return value;
+ } catch (NumberFormatException e) {
+ throw new NumberFormatException("is not a number");
+ }
+ }
+
+ public static int parseIntPercentage(String s) throws NumberFormatException {
+ if (!s.endsWith("%")) {
+ throw new NumberFormatException("does not end in %");
+ }
+ s = s.substring(0, s.length() - 1);
+ // parseInt allows "-0" that returns 0, so check for non-digits
+ if (s.matches(".*[^0-9].*")) {
+ throw new NumberFormatException("contains an invalid character");
+ }
+
+ try {
+ int value = Integer.parseInt(s);
+ if (value < 0 || value > 100) {
+ throw new NumberFormatException("is out of range");
+ }
+ return value;
+ } catch (NumberFormatException e) {
+ throw new NumberFormatException("is not a number");
+ }
+ }
+
+ public static long parseTimestampMs(String s) throws NumberFormatException {
+ if (!s.matches("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}")) {
+ throw new NumberFormatException("has invalid format");
+ }
+
+ String[] parts = s.split("\\.", 2);
+ long value = 0;
+ for (String group: parts[0].split(":")) {
+ value = value * 60 + Long.parseLong(group);
+ }
+ return value * 1000 + Long.parseLong(parts[1]);
+ }
+
+ public static String timeToString(long timeMs) {
+ return String.format("%d:%02d:%02d.%03d",
+ timeMs / 3600000, (timeMs / 60000) % 60,
+ (timeMs / 1000) % 60, timeMs % 1000);
+ }
+
+ public void parse(String s) {
+ boolean trailingCR = false;
+ mBuffer = (mBuffer + s.replace("\0", "\ufffd")).replace("\r\n", "\n");
+
+ /* keep trailing '\r' in case matching '\n' arrives in next packet */
+ if (mBuffer.endsWith("\r")) {
+ trailingCR = true;
+ mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
+ }
+
+ String[] lines = mBuffer.split("[\r\n]");
+ for (int i = 0; i < lines.length - 1; i++) {
+ mPhase.parse(lines[i]);
+ }
+
+ mBuffer = lines[lines.length - 1];
+ if (trailingCR)
+ mBuffer += "\r";
+ }
+
+ public void eos() {
+ if (mBuffer.endsWith("\r")) {
+ mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
+ }
+
+ mPhase.parse(mBuffer);
+ mBuffer = "";
+
+ yieldCue();
+ mPhase = mParseStart;
+ }
+
+ public void yieldCue() {
+ if (mCue != null && mCueTexts.size() > 0) {
+ mCue.mStrings = new String[mCueTexts.size()];
+ mCueTexts.toArray(mCue.mStrings);
+ mCueTexts.clear();
+ mListener.onCueParsed(mCue);
+ }
+ mCue = null;
+ }
+
+ interface Phase {
+ void parse(String line);
+ }
+
+ final private Phase mSkipRest = new Phase() {
+ @Override
+ public void parse(String line) { }
+ };
+
+ final private Phase mParseStart = new Phase() { // 5-9
+ @Override
+ public void parse(String line) {
+ if (!line.equals("WEBVTT") &&
+ !line.startsWith("WEBVTT ") &&
+ !line.startsWith("WEBVTT\t")) {
+ log_warning("Not a WEBVTT header", line);
+ mPhase = mSkipRest;
+ } else {
+ mPhase = mParseHeader;
+ }
+ }
+ };
+
+ final private Phase mParseHeader = new Phase() { // 10-13
+ TextTrackRegion parseRegion(String s) {
+ TextTrackRegion region = new TextTrackRegion();
+ for (String setting: s.split(" +")) {
+ int equalAt = setting.indexOf('=');
+ if (equalAt <= 0 || equalAt == setting.length() - 1) {
+ continue;
+ }
+
+ String name = setting.substring(0, equalAt);
+ String value = setting.substring(equalAt + 1);
+ if (name.equals("id")) {
+ region.mId = value;
+ } else if (name.equals("width")) {
+ try {
+ region.mWidth = parseFloatPercentage(value);
+ } catch (NumberFormatException e) {
+ log_warning("region setting", name,
+ "has invalid value", e.getMessage(), value);
+ }
+ } else if (name.equals("lines")) {
+ try {
+ int lines = Integer.parseInt(value);
+ if (lines >= 0) {
+ region.mLines = lines;
+ } else {
+ log_warning("region setting", name, "is negative", value);
+ }
+ } catch (NumberFormatException e) {
+ log_warning("region setting", name, "is not numeric", value);
+ }
+ } else if (name.equals("regionanchor") ||
+ name.equals("viewportanchor")) {
+ int commaAt = value.indexOf(",");
+ if (commaAt < 0) {
+ log_warning("region setting", name, "contains no comma", value);
+ continue;
+ }
+
+ String anchorX = value.substring(0, commaAt);
+ String anchorY = value.substring(commaAt + 1);
+ float x, y;
+
+ try {
+ x = parseFloatPercentage(anchorX);
+ } catch (NumberFormatException e) {
+ log_warning("region setting", name,
+ "has invalid x component", e.getMessage(), anchorX);
+ continue;
+ }
+ try {
+ y = parseFloatPercentage(anchorY);
+ } catch (NumberFormatException e) {
+ log_warning("region setting", name,
+ "has invalid y component", e.getMessage(), anchorY);
+ continue;
+ }
+
+ if (name.charAt(0) == 'r') {
+ region.mAnchorPointX = x;
+ region.mAnchorPointY = y;
+ } else {
+ region.mViewportAnchorPointX = x;
+ region.mViewportAnchorPointY = y;
+ }
+ } else if (name.equals("scroll")) {
+ if (value.equals("up")) {
+ region.mScrollValue =
+ TextTrackRegion.SCROLL_VALUE_SCROLL_UP;
+ } else {
+ log_warning("region setting", name, "has invalid value", value);
+ }
+ }
+ }
+ return region;
+ }
+
+ @Override
+ public void parse(String line) {
+ if (line.length() == 0) {
+ mPhase = mParseCueId;
+ } else if (line.contains("-->")) {
+ mPhase = mParseCueTime;
+ mPhase.parse(line);
+ } else {
+ int colonAt = line.indexOf(':');
+ if (colonAt <= 0 || colonAt >= line.length() - 1) {
+ log_warning("meta data header has invalid format", line);
+ }
+ String name = line.substring(0, colonAt);
+ String value = line.substring(colonAt + 1);
+
+ if (name.equals("Region")) {
+ TextTrackRegion region = parseRegion(value);
+ mListener.onRegionParsed(region);
+ }
+ }
+ }
+ };
+
+ final private Phase mParseCueId = new Phase() {
+ @Override
+ public void parse(String line) {
+ if (line.length() == 0) {
+ return;
+ }
+
+ assert(mCue == null);
+
+ if (line.equals("NOTE") || line.startsWith("NOTE ")) {
+ mPhase = mParseCueText;
+ }
+
+ mCue = new TextTrackCue();
+ mCueTexts.clear();
+
+ mPhase = mParseCueTime;
+ if (line.contains("-->")) {
+ mPhase.parse(line);
+ } else {
+ mCue.mId = line;
+ }
+ }
+ };
+
+ final private Phase mParseCueTime = new Phase() {
+ @Override
+ public void parse(String line) {
+ int arrowAt = line.indexOf("-->");
+ if (arrowAt < 0) {
+ mCue = null;
+ mPhase = mParseCueId;
+ return;
+ }
+
+ String start = line.substring(0, arrowAt).trim();
+ // convert only initial and first other white-space to space
+ String rest = line.substring(arrowAt + 3)
+ .replaceFirst("^\\s+", "").replaceFirst("\\s+", " ");
+ int spaceAt = rest.indexOf(' ');
+ String end = spaceAt > 0 ? rest.substring(0, spaceAt) : rest;
+ rest = spaceAt > 0 ? rest.substring(spaceAt + 1) : "";
+
+ mCue.mStartTimeMs = parseTimestampMs(start);
+ mCue.mEndTimeMs = parseTimestampMs(end);
+ for (String setting: rest.split(" +")) {
+ int colonAt = setting.indexOf(':');
+ if (colonAt <= 0 || colonAt == setting.length() - 1) {
+ continue;
+ }
+ String name = setting.substring(0, colonAt);
+ String value = setting.substring(colonAt + 1);
+
+ if (name.equals("region")) {
+ mCue.mRegionId = value;
+ } else if (name.equals("vertical")) {
+ if (value.equals("rl")) {
+ mCue.mWritingDirection =
+ TextTrackCue.WRITING_DIRECTION_VERTICAL_RL;
+ } else if (value.equals("lr")) {
+ mCue.mWritingDirection =
+ TextTrackCue.WRITING_DIRECTION_VERTICAL_LR;
+ } else {
+ log_warning("cue setting", name, "has invalid value", value);
+ }
+ } else if (name.equals("line")) {
+ try {
+ int linePosition;
+ /* TRICKY: we know that there are no spaces in value */
+ assert(value.indexOf(' ') < 0);
+ if (value.endsWith("%")) {
+ linePosition = Integer.parseInt(
+ value.substring(0, value.length() - 1));
+ if (linePosition < 0 || linePosition > 100) {
+ log_warning("cue setting", name, "is out of range", value);
+ continue;
+ }
+ mCue.mSnapToLines = false;
+ mCue.mLinePosition = linePosition;
+ } else {
+ mCue.mSnapToLines = true;
+ mCue.mLinePosition = Integer.parseInt(value);
+ }
+ } catch (NumberFormatException e) {
+ log_warning("cue setting", name,
+ "is not numeric or percentage", value);
+ }
+ } else if (name.equals("position")) {
+ try {
+ mCue.mTextPosition = parseIntPercentage(value);
+ } catch (NumberFormatException e) {
+ log_warning("cue setting", name,
+ "is not numeric or percentage", value);
+ }
+ } else if (name.equals("size")) {
+ try {
+ mCue.mSize = parseIntPercentage(value);
+ } catch (NumberFormatException e) {
+ log_warning("cue setting", name,
+ "is not numeric or percentage", value);
+ }
+ } else if (name.equals("align")) {
+ if (value.equals("start")) {
+ mCue.mAlignment = TextTrackCue.ALIGNMENT_START;
+ } else if (value.equals("middle")) {
+ mCue.mAlignment = TextTrackCue.ALIGNMENT_MIDDLE;
+ } else if (value.equals("end")) {
+ mCue.mAlignment = TextTrackCue.ALIGNMENT_END;
+ } else if (value.equals("left")) {
+ mCue.mAlignment = TextTrackCue.ALIGNMENT_LEFT;
+ } else if (value.equals("right")) {
+ mCue.mAlignment = TextTrackCue.ALIGNMENT_RIGHT;
+ } else {
+ log_warning("cue setting", name, "has invalid value", value);
+ continue;
+ }
+ }
+ }
+
+ if (mCue.mLinePosition != null ||
+ mCue.mSize != 100 ||
+ (mCue.mWritingDirection !=
+ TextTrackCue.WRITING_DIRECTION_HORIZONTAL)) {
+ mCue.mRegionId = "";
+ }
+
+ mPhase = mParseCueText;
+ }
+ };
+
+ /* also used for notes */
+ final private Phase mParseCueText = new Phase() {
+ @Override
+ public void parse(String line) {
+ if (line.length() == 0) {
+ yieldCue();
+ mPhase = mParseCueId;
+ return;
+ } else if (mCue != null) {
+ mCueTexts.add(line);
+ }
+ }
+ };
+
+ private void log_warning(
+ String nameType, String name, String message,
+ String subMessage, String value) {
+ Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
+ message + " ('" + value + "' " + subMessage + ")");
+ }
+
+ private void log_warning(
+ String nameType, String name, String message, String value) {
+ Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
+ message + " ('" + value + "')");
+ }
+
+ private void log_warning(String message, String value) {
+ Log.w(this.getClass().getName(), message + " ('" + value + "')");
+ }
+}
+
+/** @hide */
+interface WebVttCueListener {
+ void onCueParsed(TextTrackCue cue);
+ void onRegionParsed(TextTrackRegion region);
+}
+
+/** @hide */
+class WebVttTrack extends SubtitleTrack implements WebVttCueListener {
+ private static final String TAG = "WebVttTrack";
+
+ private final TextView mTextView;
+
+ private final WebVttParser mParser = new WebVttParser(this);
+ private final UnstyledTextExtractor mExtractor =
+ new UnstyledTextExtractor();
+ private final Tokenizer mTokenizer = new Tokenizer(mExtractor);
+ private final Vector<Long> mTimestamps = new Vector<Long>();
+
+ private final Map<String, TextTrackRegion> mRegions =
+ new HashMap<String, TextTrackRegion>();
+ private Long mCurrentRunID;
+
+ WebVttTrack(MediaFormat format, TextView textView) {
+ super(format);
+ mTextView = textView;
+ }
+
+ @Override
+ public View getView() {
+ return mTextView;
+ }
+
+ @Override
+ public void onData(String data, boolean eos, long runID) {
+ // implement intermixing restriction for WebVTT only for now
+ synchronized(mParser) {
+ if (mCurrentRunID != null && runID != mCurrentRunID) {
+ throw new IllegalStateException(
+ "Run #" + mCurrentRunID +
+ " in progress. Cannot process run #" + runID);
+ }
+ mCurrentRunID = runID;
+ mParser.parse(data);
+ if (eos) {
+ finishedRun(runID);
+ mParser.eos();
+ mRegions.clear();
+ mCurrentRunID = null;
+ }
+ }
+ }
+
+ @Override
+ public void onCueParsed(TextTrackCue cue) {
+ synchronized (mParser) {
+ // resolve region
+ if (cue.mRegionId.length() != 0) {
+ cue.mRegion = mRegions.get(cue.mRegionId);
+ }
+
+ if (DEBUG) Log.v(TAG, "adding cue " + cue);
+
+ // tokenize text track string-lines into lines of spans
+ mTokenizer.reset();
+ for (String s: cue.mStrings) {
+ mTokenizer.tokenize(s);
+ }
+ cue.mLines = mExtractor.getText();
+ if (DEBUG) Log.v(TAG, cue.appendLinesToBuilder(
+ cue.appendStringsToBuilder(
+ new StringBuilder()).append(" simplified to: "))
+ .toString());
+
+ // extract inner timestamps
+ for (TextTrackCueSpan[] line: cue.mLines) {
+ for (TextTrackCueSpan span: line) {
+ if (span.mTimestampMs > cue.mStartTimeMs &&
+ span.mTimestampMs < cue.mEndTimeMs &&
+ !mTimestamps.contains(span.mTimestampMs)) {
+ mTimestamps.add(span.mTimestampMs);
+ }
+ }
+ }
+
+ if (mTimestamps.size() > 0) {
+ cue.mInnerTimesMs = new long[mTimestamps.size()];
+ for (int ix=0; ix < mTimestamps.size(); ++ix) {
+ cue.mInnerTimesMs[ix] = mTimestamps.get(ix);
+ }
+ mTimestamps.clear();
+ } else {
+ cue.mInnerTimesMs = null;
+ }
+
+ cue.mRunID = mCurrentRunID;
+ }
+
+ addCue(cue);
+ }
+
+ @Override
+ public void onRegionParsed(TextTrackRegion region) {
+ synchronized(mParser) {
+ mRegions.put(region.mId, region);
+ }
+ }
+
+ public void updateView(Vector<SubtitleTrack.Cue> activeCues) {
+ if (!mVisible) {
+ // don't keep the state if we are not visible
+ return;
+ }
+
+ if (DEBUG && mTimeProvider != null) {
+ try {
+ Log.d(TAG, "at " +
+ (mTimeProvider.getCurrentTimeUs(false, true) / 1000) +
+ " ms the active cues are:");
+ } catch (IllegalStateException e) {
+ Log.d(TAG, "at (illegal state) the active cues are:");
+ }
+ }
+ StringBuilder text = new StringBuilder();
+ StringBuilder lineBuilder = new StringBuilder();
+ for (Cue o: activeCues) {
+ TextTrackCue cue = (TextTrackCue)o;
+ if (DEBUG) Log.d(TAG, cue.toString());
+ for (TextTrackCueSpan[] line: cue.mLines) {
+ for (TextTrackCueSpan span: line) {
+ if (!span.mEnabled) {
+ continue;
+ }
+ lineBuilder.append(span.mText);
+ }
+ if (lineBuilder.length() > 0) {
+ text.append(lineBuilder.toString()).append("\n");
+ lineBuilder.delete(0, lineBuilder.length());
+ }
+ }
+ }
+
+ if (mTextView != null) {
+ if (DEBUG) Log.d(TAG, "updating to " + text.toString());
+ mTextView.setText(text.toString());
+ mTextView.postInvalidate();
+ }
+ }
+}
diff --git a/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java b/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java
index 926719c..9621f92 100644
--- a/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java
+++ b/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java
@@ -50,12 +50,20 @@ public class RationalTest extends junit.framework.TestCase {
assertEquals(1, r.getNumerator());
assertEquals(2, r.getDenominator());
- // Dividing by zero is not allowed
- try {
- r = new Rational(1, 0);
- fail("Expected Rational constructor to throw an IllegalArgumentException");
- } catch(IllegalArgumentException e) {
- }
+ // Infinity.
+ r = new Rational(1, 0);
+ assertEquals(0, r.getNumerator());
+ assertEquals(0, r.getDenominator());
+
+ // Negative infinity.
+ r = new Rational(-1, 0);
+ assertEquals(0, r.getNumerator());
+ assertEquals(0, r.getDenominator());
+
+ // NaN.
+ r = new Rational(0, 0);
+ assertEquals(0, r.getNumerator());
+ assertEquals(0, r.getDenominator());
}
@SmallTest
@@ -110,5 +118,34 @@ public class RationalTest extends junit.framework.TestCase {
assertEquals(moreComplicated, moreComplicated2);
assertEquals(moreComplicated2, moreComplicated);
+ Rational nan = new Rational(0, 0);
+ Rational nan2 = new Rational(0, 0);
+ assertTrue(nan.equals(nan));
+ assertTrue(nan.equals(nan2));
+ assertTrue(nan2.equals(nan));
+ assertFalse(nan.equals(r));
+ assertFalse(r.equals(nan));
+
+ // Infinities of the same sign are equal.
+ Rational posInf = new Rational(1, 0);
+ Rational posInf2 = new Rational(2, 0);
+ Rational negInf = new Rational(-1, 0);
+ Rational negInf2 = new Rational(-2, 0);
+ assertEquals(posInf, posInf);
+ assertEquals(negInf, negInf);
+ assertEquals(posInf, posInf2);
+ assertEquals(negInf, negInf2);
+
+ // Infinities aren't equal to anything else.
+ assertFalse(posInf.equals(negInf));
+ assertFalse(negInf.equals(posInf));
+ assertFalse(negInf.equals(r));
+ assertFalse(posInf.equals(r));
+ assertFalse(r.equals(negInf));
+ assertFalse(r.equals(posInf));
+ assertFalse(posInf.equals(nan));
+ assertFalse(negInf.equals(nan));
+ assertFalse(nan.equals(posInf));
+ assertFalse(nan.equals(negInf));
}
-} \ No newline at end of file
+}