From 03c25794b66b0d01e0e850042713f8009c787dc2 Mon Sep 17 00:00:00 2001 From: Lajos Molnar Date: Thu, 15 Aug 2013 16:12:54 -0700 Subject: Internal subtitle base support Change-Id: I3fc57d6280773dc24f4822be21c9497ae70f7374 Signed-off-by: Lajos Molnar Bug: 10326117 --- media/java/android/media/MediaTimeProvider.java | 90 ++++ media/java/android/media/SubtitleController.java | 309 +++++++++++ media/java/android/media/SubtitleTrack.java | 648 +++++++++++++++++++++++ 3 files changed, 1047 insertions(+) create mode 100644 media/java/android/media/MediaTimeProvider.java create mode 100644 media/java/android/media/SubtitleController.java create mode 100644 media/java/android/media/SubtitleTrack.java (limited to 'media') diff --git a/media/java/android/media/MediaTimeProvider.java b/media/java/android/media/MediaTimeProvider.java new file mode 100644 index 0000000..c1fda81 --- /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. + * @throw 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..edd569c --- /dev/null +++ b/media/java/android/media/SubtitleController.java @@ -0,0 +1,309 @@ +/* + * 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 mRenderers; + private Vector mTracks; + private SubtitleTrack mSelectedTrack; + private boolean mShowing; + + /** + * 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(); + mShowing = false; + mTracks = new Vector(); + } + + /** + * @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 = CaptioningManager.getLocale(mContext.getContentResolver()); + + 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 (CaptioningManager.isEnabled(mContext.getContentResolver())) { + 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 mRunsByEndTime = new LongSparseArray(); + /** @hide TODO private */ + final protected LongSparseArray mRunsByID = new LongSparseArray(); + + /** @hide TODO private */ + protected CueList mCues; + /** @hide TODO private */ + final protected Vector mActiveCues = new Vector(); + /** @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 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 > it = + mCues.entriesBetween(mLastUpdateTimeMs, timeMs).iterator(); it.hasNext(); ) { + Pair 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 > mCues; + public boolean DEBUG = false; + + private boolean addEvent(Cue cue, long timeMs) { + Vector cues = mCues.get(timeMs); + if (cues == null) { + cues = new Vector(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 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> entriesBetween( + final long lastTimeMs, final long timeMs) { + return new Iterable >() { + @Override + public Iterator > 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> 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 > { + @Override + public boolean hasNext() { + return !mDone; + } + + @Override + public Pair next() { + if (mDone) { + throw new NoSuchElementException(""); + } + mLastEntry = new Pair( + 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 > 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 mListIterator; + private boolean mDone; + private SortedMap > mRemainingCues; + private Iterator mLastListIterator; + private Pair mLastEntry; + } + + CueList() { + mCues = new TreeMap>(); + } + } + + /** @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 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; + } + } + } +} -- cgit v1.1