diff options
Diffstat (limited to 'media')
-rw-r--r-- | media/java/android/media/MediaTimeProvider.java | 90 | ||||
-rw-r--r-- | media/java/android/media/SubtitleController.java | 312 | ||||
-rw-r--r-- | media/java/android/media/SubtitleTrack.java | 648 | ||||
-rw-r--r-- | media/java/android/media/WebVttRenderer.java | 1094 | ||||
-rw-r--r-- | media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RationalTest.java | 51 |
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("&", "&", pos) || + replaceEscape("<", "<", pos) || + replaceEscape(">", ">", pos) || + replaceEscape("‎", "\u200e", pos) || + replaceEscape("‏", "\u200f", pos) || + replaceEscape(" ", "\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 +} |