| /* |
| * 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.graphics.Canvas; |
| import android.media.MediaPlayer.TrackInfo; |
| import android.os.Handler; |
| import android.util.Log; |
| import android.util.LongSparseArray; |
| import android.util.Pair; |
| |
| 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 final MediaFormat getFormat() { |
| return mFormat; |
| } |
| |
| private long mNextScheduledTimeMs = -1; |
| |
| protected void onData(SubtitleData data) { |
| long runID = data.getStartTimeUs() + 1; |
| onData(data.getData(), true /* eos */, runID); |
| setRunDiscardTimeMs( |
| runID, |
| (data.getStartTimeUs() + data.getDurationUs()) / 1000); |
| } |
| |
| /** |
| * 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 subtitle data byte buffer |
| * @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). |
| */ |
| public abstract void onData(byte[] data, boolean eos, long runID); |
| |
| /** |
| * Called when adding the subtitle rendering widget to the view hierarchy, |
| * as well as when showing or hiding the subtitle track, or when the video |
| * surface position has changed. |
| * |
| * @return the widget that renders this subtitle track. For most renderers |
| * there should be a single shared instance that is used for all |
| * tracks supported by that renderer, as at most one subtitle track |
| * is visible at one time. |
| */ |
| public abstract RenderingWidget getRenderingWidget(); |
| |
| /** |
| * 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 */ |
| protected 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; |
| RenderingWidget renderingWidget = getRenderingWidget(); |
| if (renderingWidget != null) { |
| renderingWidget.setVisible(true); |
| } |
| if (mTimeProvider != null) { |
| mTimeProvider.scheduleUpdate(this); |
| } |
| } |
| |
| /** @hide */ |
| public void hide() { |
| if (!mVisible) { |
| return; |
| } |
| |
| if (mTimeProvider != null) { |
| mTimeProvider.cancelNotifications(this); |
| } |
| RenderingWidget renderingWidget = getRenderingWidget(); |
| if (renderingWidget != null) { |
| renderingWidget.setVisible(false); |
| } |
| 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 synchronized 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 whether this is a text track who fires events instead getting rendered */ |
| public int getTrackType() { |
| return getRenderingWidget() == null |
| ? TrackInfo.MEDIA_TRACK_TYPE_TIMEDTEXT |
| : TrackInfo.MEDIA_TRACK_TYPE_SUBTITLE; |
| } |
| |
| |
| /** @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; |
| } |
| } |
| } |
| |
| /** |
| * Interface for rendering subtitles onto a Canvas. |
| */ |
| public interface RenderingWidget { |
| /** |
| * Sets the widget's callback, which is used to send updates when the |
| * rendered data has changed. |
| * |
| * @param callback update callback |
| */ |
| public void setOnChangedListener(OnChangedListener callback); |
| |
| /** |
| * Sets the widget's size. |
| * |
| * @param width width in pixels |
| * @param height height in pixels |
| */ |
| public void setSize(int width, int height); |
| |
| /** |
| * Sets whether the widget should draw subtitles. |
| * |
| * @param visible true if subtitles should be drawn, false otherwise |
| */ |
| public void setVisible(boolean visible); |
| |
| /** |
| * Renders subtitles onto a {@link Canvas}. |
| * |
| * @param c canvas on which to render subtitles |
| */ |
| public void draw(Canvas c); |
| |
| /** |
| * Called when the widget is attached to a window. |
| */ |
| public void onAttachedToWindow(); |
| |
| /** |
| * Called when the widget is detached from a window. |
| */ |
| public void onDetachedFromWindow(); |
| |
| /** |
| * Callback used to send updates about changes to rendering data. |
| */ |
| public interface OnChangedListener { |
| /** |
| * Called when the rendering data has changed. |
| * |
| * @param renderingWidget the widget whose data has changed |
| */ |
| public void onChanged(RenderingWidget renderingWidget); |
| } |
| } |
| } |