| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tv; |
| |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.os.Handler; |
| import android.os.Message; |
| import android.support.annotation.IntDef; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.Nullable; |
| import android.support.annotation.VisibleForTesting; |
| import android.util.Log; |
| import android.util.Range; |
| import com.android.tv.analytics.Tracker; |
| import com.android.tv.common.SoftPreconditions; |
| import com.android.tv.common.WeakHandler; |
| import com.android.tv.data.OnCurrentProgramUpdatedListener; |
| import com.android.tv.data.Program; |
| import com.android.tv.data.ProgramDataManager; |
| import com.android.tv.data.api.Channel; |
| import com.android.tv.ui.TunableTvView; |
| import com.android.tv.ui.api.TunableTvViewPlayingApi.TimeShiftListener; |
| import com.android.tv.util.AsyncDbTask; |
| import com.android.tv.util.TimeShiftUtils; |
| import com.android.tv.util.Utils; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Iterator; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.Queue; |
| import java.util.concurrent.TimeUnit; |
| |
| /** |
| * A class which manages the time shift feature in Live TV. It consists of two parts. {@link |
| * PlayController} controls the playback such as play/pause, rewind and fast-forward using {@link |
| * TunableTvView} which communicates with TvInputService through {@link |
| * android.media.tv.TvInputService.Session}. {@link ProgramManager} loads programs of the current |
| * channel in the background. |
| */ |
| public class TimeShiftManager { |
| private static final String TAG = "TimeShiftManager"; |
| private static final boolean DEBUG = false; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({PLAY_STATUS_PAUSED, PLAY_STATUS_PLAYING}) |
| public @interface PlayStatus {} |
| |
| public static final int PLAY_STATUS_PAUSED = 0; |
| public static final int PLAY_STATUS_PLAYING = 1; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({PLAY_SPEED_1X, PLAY_SPEED_2X, PLAY_SPEED_3X, PLAY_SPEED_4X, PLAY_SPEED_5X}) |
| public @interface PlaySpeed {} |
| |
| public static final int PLAY_SPEED_1X = 1; |
| public static final int PLAY_SPEED_2X = 2; |
| public static final int PLAY_SPEED_3X = 3; |
| public static final int PLAY_SPEED_4X = 4; |
| public static final int PLAY_SPEED_5X = 5; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({PLAY_DIRECTION_FORWARD, PLAY_DIRECTION_BACKWARD}) |
| public @interface PlayDirection {} |
| |
| public static final int PLAY_DIRECTION_FORWARD = 0; |
| public static final int PLAY_DIRECTION_BACKWARD = 1; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef( |
| flag = true, |
| value = { |
| TIME_SHIFT_ACTION_ID_PLAY, |
| TIME_SHIFT_ACTION_ID_PAUSE, |
| TIME_SHIFT_ACTION_ID_REWIND, |
| TIME_SHIFT_ACTION_ID_FAST_FORWARD, |
| TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS, |
| TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT |
| }) |
| public @interface TimeShiftActionId {} |
| |
| public static final int TIME_SHIFT_ACTION_ID_PLAY = 1; |
| public static final int TIME_SHIFT_ACTION_ID_PAUSE = 1 << 1; |
| public static final int TIME_SHIFT_ACTION_ID_REWIND = 1 << 2; |
| public static final int TIME_SHIFT_ACTION_ID_FAST_FORWARD = 1 << 3; |
| public static final int TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS = 1 << 4; |
| public static final int TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT = 1 << 5; |
| |
| private static final int MSG_GET_CURRENT_POSITION = 1000; |
| private static final int MSG_PREFETCH_PROGRAM = 1001; |
| private static final long REQUEST_CURRENT_POSITION_INTERVAL = TimeUnit.SECONDS.toMillis(1); |
| private static final long MAX_DUMMY_PROGRAM_DURATION = TimeUnit.MINUTES.toMillis(30); |
| @VisibleForTesting static final long INVALID_TIME = -1; |
| static final long CURRENT_TIME = -2; |
| private static final long PREFETCH_TIME_OFFSET_FROM_PROGRAM_END = TimeUnit.MINUTES.toMillis(1); |
| private static final long PREFETCH_DURATION_FOR_NEXT = TimeUnit.HOURS.toMillis(2); |
| |
| private static final long ALLOWED_START_TIME_OFFSET = TimeUnit.DAYS.toMillis(14); |
| private static final long TWO_WEEKS_MS = TimeUnit.DAYS.toMillis(14); |
| |
| @VisibleForTesting static final long REQUEST_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(3); |
| |
| /** |
| * If the user presses the {@link android.view.KeyEvent#KEYCODE_MEDIA_PREVIOUS} button within |
| * this threshold from the program start time, the play position moves to the start of the |
| * previous program. Otherwise, the play position moves to the start of the current program. |
| * This value is specified in the UX document. |
| */ |
| private static final long PROGRAM_START_TIME_THRESHOLD = TimeUnit.SECONDS.toMillis(3); |
| /** |
| * If the current position enters within this range from the recording start time, rewind action |
| * and jump to previous action is disabled. Similarly, if the current position enters within |
| * this range from the current system time, fast forward action and jump to next action is |
| * disabled. It must be three times longer than {@link #REQUEST_CURRENT_POSITION_INTERVAL} at |
| * least. |
| */ |
| private static final long DISABLE_ACTION_THRESHOLD = 3 * REQUEST_CURRENT_POSITION_INTERVAL; |
| /** |
| * If the current position goes out of this range from the recording start time, rewind action |
| * and jump to previous action is enabled. Similarly, if the current position goes out of this |
| * range from the current system time, fast forward action and jump to next action is enabled. |
| * Enable threshold and disable threshold must be different because the current position does |
| * not have the continuous value. It changes every one second. |
| */ |
| private static final long ENABLE_ACTION_THRESHOLD = |
| DISABLE_ACTION_THRESHOLD + 3 * REQUEST_CURRENT_POSITION_INTERVAL; |
| /** |
| * The current position sent from TIS can not be exactly the same as the current system time due |
| * to the elapsed time to pass the message from TIS to Live TV. So the boundary threshold |
| * is necessary. The same goes for the recording start time. It's the same {@link |
| * #REQUEST_CURRENT_POSITION_INTERVAL}. |
| */ |
| private static final long RECORDING_BOUNDARY_THRESHOLD = REQUEST_CURRENT_POSITION_INTERVAL; |
| |
| private final PlayController mPlayController; |
| private final ProgramManager mProgramManager; |
| private final Tracker mTracker; |
| |
| @VisibleForTesting |
| final CurrentPositionMediator mCurrentPositionMediator = new CurrentPositionMediator(); |
| |
| private Listener mListener; |
| private final OnCurrentProgramUpdatedListener mOnCurrentProgramUpdatedListener; |
| private int mEnabledActionIds = |
| TIME_SHIFT_ACTION_ID_PLAY |
| | TIME_SHIFT_ACTION_ID_PAUSE |
| | TIME_SHIFT_ACTION_ID_REWIND |
| | TIME_SHIFT_ACTION_ID_FAST_FORWARD |
| | TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS |
| | TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT; |
| @TimeShiftActionId private int mLastActionId = 0; |
| |
| private final Context mContext; |
| |
| private Program mCurrentProgram; |
| // This variable is used to block notification while changing the availability status. |
| private boolean mNotificationEnabled; |
| |
| private final Handler mHandler = new TimeShiftHandler(this); |
| |
| public TimeShiftManager( |
| Context context, |
| TunableTvView tvView, |
| ProgramDataManager programDataManager, |
| Tracker tracker, |
| OnCurrentProgramUpdatedListener onCurrentProgramUpdatedListener) { |
| mContext = context; |
| mPlayController = new PlayController(tvView); |
| mProgramManager = new ProgramManager(programDataManager); |
| mTracker = tracker; |
| mOnCurrentProgramUpdatedListener = onCurrentProgramUpdatedListener; |
| } |
| |
| /** Sets a listener which will receive events from this class. */ |
| public void setListener(Listener listener) { |
| mListener = listener; |
| } |
| |
| /** Checks if the trick play is available for the current channel. */ |
| public boolean isAvailable() { |
| return mPlayController.mAvailable; |
| } |
| |
| /** Returns the current time position in milliseconds. */ |
| public long getCurrentPositionMs() { |
| return mCurrentPositionMediator.mCurrentPositionMs; |
| } |
| |
| void setCurrentPositionMs(long currentTimeMs) { |
| mCurrentPositionMediator.onCurrentPositionChanged(currentTimeMs); |
| } |
| |
| /** Returns the start time of the recording in milliseconds. */ |
| public long getRecordStartTimeMs() { |
| long oldestProgramStartTime = mProgramManager.getOldestProgramStartTime(); |
| return oldestProgramStartTime == INVALID_TIME |
| ? INVALID_TIME |
| : mPlayController.mRecordStartTimeMs; |
| } |
| |
| /** Returns the end time of the recording in milliseconds. */ |
| public long getRecordEndTimeMs() { |
| if (mPlayController.mRecordEndTimeMs == CURRENT_TIME) { |
| return System.currentTimeMillis(); |
| } else { |
| return mPlayController.mRecordEndTimeMs; |
| } |
| } |
| |
| /** |
| * Plays the media. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void play() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_PLAY)) { |
| return; |
| } |
| mTracker.sendTimeShiftAction(TIME_SHIFT_ACTION_ID_PLAY); |
| mLastActionId = TIME_SHIFT_ACTION_ID_PLAY; |
| mPlayController.play(); |
| updateActions(); |
| } |
| |
| /** |
| * Pauses the playback. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void pause() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_PAUSE)) { |
| return; |
| } |
| mLastActionId = TIME_SHIFT_ACTION_ID_PAUSE; |
| mTracker.sendTimeShiftAction(mLastActionId); |
| mPlayController.pause(); |
| updateActions(); |
| } |
| |
| /** |
| * Toggles the playing and paused state. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void togglePlayPause() { |
| mPlayController.togglePlayPause(); |
| } |
| |
| /** |
| * Plays the media in backward direction. The playback speed is increased by 1x each time this |
| * is called. The range of the speed is from 2x to 5x. If the playing position is considered the |
| * same as the record start time, it does nothing |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void rewind() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_REWIND)) { |
| return; |
| } |
| mLastActionId = TIME_SHIFT_ACTION_ID_REWIND; |
| mTracker.sendTimeShiftAction(mLastActionId); |
| mPlayController.rewind(); |
| updateActions(); |
| } |
| |
| /** |
| * Plays the media in forward direction. The playback speed is increased by 1x each time this is |
| * called. The range of the speed is from 2x to 5x. If the playing position is the same as the |
| * current time, it does nothing. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void fastForward() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_FAST_FORWARD)) { |
| return; |
| } |
| mLastActionId = TIME_SHIFT_ACTION_ID_FAST_FORWARD; |
| mTracker.sendTimeShiftAction(mLastActionId); |
| mPlayController.fastForward(); |
| updateActions(); |
| } |
| |
| /** |
| * Jumps to the start of the current program. If the currently playing position is within 3 |
| * seconds (={@link #PROGRAM_START_TIME_THRESHOLD})from the start time of the program, it goes |
| * to the start of the previous program if exists. If the playing position is the same as the |
| * record start time, it does nothing. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void jumpToPrevious() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS)) { |
| return; |
| } |
| Program program = |
| mProgramManager.getProgramAt( |
| mCurrentPositionMediator.mCurrentPositionMs - PROGRAM_START_TIME_THRESHOLD); |
| if (program == null) { |
| return; |
| } |
| long seekPosition = |
| Math.max(program.getStartTimeUtcMillis(), mPlayController.mRecordStartTimeMs); |
| mLastActionId = TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS; |
| mTracker.sendTimeShiftAction(mLastActionId); |
| mPlayController.seekTo(seekPosition); |
| mCurrentPositionMediator.onSeekRequested(seekPosition); |
| updateActions(); |
| } |
| |
| /** |
| * Jumps to the start of the next program if exists. If there's no next program, it jumps to the |
| * current system time and shows the live TV. If the playing position is considered the same as |
| * the current time, it does nothing. |
| * |
| * @throws IllegalStateException if the trick play is not available. |
| */ |
| public void jumpToNext() { |
| if (!isActionEnabled(TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT)) { |
| return; |
| } |
| Program currentProgram = |
| mProgramManager.getProgramAt(mCurrentPositionMediator.mCurrentPositionMs); |
| if (currentProgram == null) { |
| return; |
| } |
| Program nextProgram = mProgramManager.getProgramAt(currentProgram.getEndTimeUtcMillis()); |
| long currentTimeMs = System.currentTimeMillis(); |
| mLastActionId = TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT; |
| mTracker.sendTimeShiftAction(mLastActionId); |
| if (nextProgram == null || nextProgram.getStartTimeUtcMillis() > currentTimeMs) { |
| mPlayController.seekTo(currentTimeMs); |
| if (mPlayController.isForwarding()) { |
| // The current position will be the current system time from now. |
| mPlayController.mIsPlayOffsetChanged = false; |
| mCurrentPositionMediator.initialize(currentTimeMs); |
| } else { |
| // The current position would not be the current system time. |
| // So need to wait for the correct time from TIS. |
| mCurrentPositionMediator.onSeekRequested(currentTimeMs); |
| } |
| } else { |
| mPlayController.seekTo(nextProgram.getStartTimeUtcMillis()); |
| mCurrentPositionMediator.onSeekRequested(nextProgram.getStartTimeUtcMillis()); |
| } |
| updateActions(); |
| } |
| |
| /** Returns the playback status. The value is PLAY_STATUS_PAUSED or PLAY_STATUS_PLAYING. */ |
| @PlayStatus |
| public int getPlayStatus() { |
| return mPlayController.mPlayStatus; |
| } |
| |
| /** |
| * Returns the displayed playback speed. The value is one of PLAY_SPEED_1X, PLAY_SPEED_2X, |
| * PLAY_SPEED_3X, PLAY_SPEED_4X and PLAY_SPEED_5X. |
| */ |
| @PlaySpeed |
| public int getDisplayedPlaySpeed() { |
| return mPlayController.mDisplayedPlaySpeed; |
| } |
| |
| /** |
| * Returns the playback speed. The value is PLAY_DIRECTION_FORWARD or PLAY_DIRECTION_BACKWARD. |
| */ |
| @PlayDirection |
| public int getPlayDirection() { |
| return mPlayController.mPlayDirection; |
| } |
| |
| /** Returns the ID of the last action.. */ |
| @TimeShiftActionId |
| public int getLastActionId() { |
| return mLastActionId; |
| } |
| |
| /** Enables or disables the time-shift actions. */ |
| @VisibleForTesting |
| void enableAction(@TimeShiftActionId int actionId, boolean enable) { |
| int oldEnabledActionIds = mEnabledActionIds; |
| if (enable) { |
| mEnabledActionIds |= actionId; |
| } else { |
| mEnabledActionIds &= ~actionId; |
| } |
| if (mNotificationEnabled && mListener != null && oldEnabledActionIds != mEnabledActionIds) { |
| mListener.onActionEnabledChanged(actionId, enable); |
| } |
| } |
| |
| public boolean isActionEnabled(@TimeShiftActionId int actionId) { |
| return (mEnabledActionIds & actionId) == actionId; |
| } |
| |
| private void updateActions() { |
| if (isAvailable()) { |
| enableAction(TIME_SHIFT_ACTION_ID_PLAY, true); |
| enableAction(TIME_SHIFT_ACTION_ID_PAUSE, true); |
| // Rewind action and jump to previous action. |
| long threshold = |
| isActionEnabled(TIME_SHIFT_ACTION_ID_REWIND) |
| ? DISABLE_ACTION_THRESHOLD |
| : ENABLE_ACTION_THRESHOLD; |
| boolean enabled = |
| mCurrentPositionMediator.mCurrentPositionMs - mPlayController.mRecordStartTimeMs |
| > threshold; |
| enableAction(TIME_SHIFT_ACTION_ID_REWIND, enabled); |
| enableAction(TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS, enabled); |
| // Fast forward action and jump to next action |
| threshold = |
| isActionEnabled(TIME_SHIFT_ACTION_ID_FAST_FORWARD) |
| ? DISABLE_ACTION_THRESHOLD |
| : ENABLE_ACTION_THRESHOLD; |
| enabled = |
| getRecordEndTimeMs() - mCurrentPositionMediator.mCurrentPositionMs > threshold; |
| enableAction(TIME_SHIFT_ACTION_ID_FAST_FORWARD, enabled); |
| enableAction(TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT, enabled); |
| } else { |
| enableAction(TIME_SHIFT_ACTION_ID_PLAY, false); |
| enableAction(TIME_SHIFT_ACTION_ID_PAUSE, false); |
| enableAction(TIME_SHIFT_ACTION_ID_REWIND, false); |
| enableAction(TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS, false); |
| enableAction(TIME_SHIFT_ACTION_ID_FAST_FORWARD, false); |
| enableAction(TIME_SHIFT_ACTION_ID_PLAY, false); |
| } |
| } |
| |
| private void updateCurrentProgram() { |
| SoftPreconditions.checkState(isAvailable(), TAG, "Time shift is not available"); |
| SoftPreconditions.checkState(mCurrentPositionMediator.mCurrentPositionMs != INVALID_TIME); |
| Program currentProgram = getProgramAt(mCurrentPositionMediator.mCurrentPositionMs); |
| if (!Program.isProgramValid(currentProgram)) { |
| currentProgram = null; |
| } |
| if (!Objects.equals(mCurrentProgram, currentProgram)) { |
| if (DEBUG) Log.d(TAG, "Current program has been updated. " + currentProgram); |
| mCurrentProgram = currentProgram; |
| if (mNotificationEnabled && mOnCurrentProgramUpdatedListener != null) { |
| Channel channel = mPlayController.getCurrentChannel(); |
| if (channel != null) { |
| mOnCurrentProgramUpdatedListener.onCurrentProgramUpdated( |
| channel.getId(), mCurrentProgram); |
| mPlayController.onCurrentProgramChanged(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Returns {@code true} if the trick play is available and it's playing to the forward direction |
| * with normal speed, otherwise {@code false}. |
| */ |
| public boolean isNormalPlaying() { |
| return mPlayController.mAvailable |
| && mPlayController.mPlayStatus == PLAY_STATUS_PLAYING |
| && mPlayController.mPlayDirection == PLAY_DIRECTION_FORWARD |
| && mPlayController.mDisplayedPlaySpeed == PLAY_SPEED_1X; |
| } |
| |
| /** Checks if the trick play is available and it's playback status is paused. */ |
| public boolean isPaused() { |
| return mPlayController.mAvailable && mPlayController.mPlayStatus == PLAY_STATUS_PAUSED; |
| } |
| |
| /** Returns the program which airs at the given time. */ |
| @NonNull |
| public Program getProgramAt(long timeMs) { |
| Program program = mProgramManager.getProgramAt(timeMs); |
| if (program == null) { |
| // Guard just in case when the program prefetch handler doesn't work on time. |
| mProgramManager.addDummyProgramsAt(timeMs); |
| program = mProgramManager.getProgramAt(timeMs); |
| } |
| return program; |
| } |
| |
| void onAvailabilityChanged() { |
| mCurrentPositionMediator.initialize(mPlayController.mRecordStartTimeMs); |
| mProgramManager.onAvailabilityChanged( |
| mPlayController.mAvailable, |
| mPlayController.getCurrentChannel(), |
| mPlayController.mRecordStartTimeMs); |
| updateActions(); |
| // Availability change notification should be always sent |
| // even if mNotificationEnabled is false. |
| if (mListener != null) { |
| mListener.onAvailabilityChanged(); |
| } |
| } |
| |
| void onRecordTimeRangeChanged() { |
| if (mPlayController.mAvailable) { |
| mProgramManager.onRecordTimeRangeChanged( |
| mPlayController.mRecordStartTimeMs, mPlayController.mRecordEndTimeMs); |
| } |
| updateActions(); |
| if (mNotificationEnabled && mListener != null) { |
| mListener.onRecordTimeRangeChanged(); |
| } |
| } |
| |
| void onCurrentPositionChanged() { |
| updateActions(); |
| updateCurrentProgram(); |
| if (mNotificationEnabled && mListener != null) { |
| mListener.onCurrentPositionChanged(); |
| } |
| } |
| |
| void onPlayStatusChanged(@PlayStatus int status) { |
| if (mNotificationEnabled && mListener != null) { |
| mListener.onPlayStatusChanged(status); |
| } |
| } |
| |
| void onProgramInfoChanged() { |
| updateCurrentProgram(); |
| if (mNotificationEnabled && mListener != null) { |
| mListener.onProgramInfoChanged(); |
| } |
| } |
| |
| /** |
| * Returns the current program which airs right now. |
| * |
| * <p>If the program is a dummy program, which means there's no program information, returns |
| * {@code null}. |
| */ |
| @Nullable |
| public Program getCurrentProgram() { |
| if (isAvailable()) { |
| return mCurrentProgram; |
| } |
| return null; |
| } |
| |
| private int getPlaybackSpeed() { |
| if (mPlayController.mDisplayedPlaySpeed == PLAY_SPEED_1X) { |
| return 1; |
| } else { |
| long durationMs = |
| (getCurrentProgram() == null ? 0 : getCurrentProgram().getDurationMillis()); |
| if (mPlayController.mDisplayedPlaySpeed > PLAY_SPEED_5X) { |
| Log.w( |
| TAG, |
| "Unknown displayed play speed is chosen : " |
| + mPlayController.mDisplayedPlaySpeed); |
| return TimeShiftUtils.getMaxPlaybackSpeed(durationMs); |
| } else { |
| return TimeShiftUtils.getPlaybackSpeed( |
| mPlayController.mDisplayedPlaySpeed - PLAY_SPEED_2X, durationMs); |
| } |
| } |
| } |
| |
| /** A class which controls the trick play. */ |
| private class PlayController { |
| private final TunableTvView mTvView; |
| |
| private long mAvailablityChangedTimeMs; |
| private long mRecordStartTimeMs; |
| private long mRecordEndTimeMs; |
| |
| @PlayStatus private int mPlayStatus = PLAY_STATUS_PAUSED; |
| @PlaySpeed private int mDisplayedPlaySpeed = PLAY_SPEED_1X; |
| @PlayDirection private int mPlayDirection = PLAY_DIRECTION_FORWARD; |
| private int mPlaybackSpeed; |
| private boolean mAvailable; |
| |
| /** |
| * Indicates that the trick play is not playing the current time position. It is set true |
| * when {@link PlayController#pause}, {@link PlayController#rewind}, {@link |
| * PlayController#fastForward} and {@link PlayController#seekTo} is called. If it is true, |
| * the current time is equal to System.currentTimeMillis(). |
| */ |
| private boolean mIsPlayOffsetChanged; |
| |
| PlayController(TunableTvView tvView) { |
| mTvView = tvView; |
| mTvView.setTimeShiftListener( |
| new TimeShiftListener() { |
| @Override |
| public void onAvailabilityChanged() { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "onAvailabilityChanged(available=" |
| + mTvView.isTimeShiftAvailable() |
| + ")"); |
| } |
| PlayController.this.onAvailabilityChanged(); |
| } |
| |
| @Override |
| public void onRecordStartTimeChanged(long recordStartTimeMs) { |
| if (!SoftPreconditions.checkState( |
| mAvailable, TAG, "Trick play is not available.")) { |
| return; |
| } |
| if (recordStartTimeMs |
| < mAvailablityChangedTimeMs - ALLOWED_START_TIME_OFFSET) { |
| Log.e( |
| TAG, |
| "The start time is too earlier than the time of availability: {" |
| + "startTime: " |
| + recordStartTimeMs |
| + ", availability: " |
| + mAvailablityChangedTimeMs); |
| return; |
| } |
| if (recordStartTimeMs > System.currentTimeMillis()) { |
| // The time reported by TvInputService might not consistent with |
| // system |
| // clock,, use system's current time instead. |
| Log.e( |
| TAG, |
| "The start time should not be earlier than the current time, " |
| + "reset the start time to the system's current time: {" |
| + "startTime: " |
| + recordStartTimeMs |
| + ", current time: " |
| + System.currentTimeMillis()); |
| recordStartTimeMs = System.currentTimeMillis(); |
| } |
| if (mRecordStartTimeMs == recordStartTimeMs) { |
| return; |
| } |
| mRecordStartTimeMs = recordStartTimeMs; |
| TimeShiftManager.this.onRecordTimeRangeChanged(); |
| |
| // According to the UX guidelines, the stream should be resumed if the |
| // recording buffer fills up while paused, which means that the current |
| // time |
| // position is the same as or before the recording start time. |
| // But, for this application and the TIS, it's an erroneous and |
| // confusing |
| // situation if the current time position is before the recording start |
| // time. |
| // So, we recommend the TIS to keep the current time position greater |
| // than or |
| // equal to the recording start time. |
| // And here, we assume that the buffer is full if the current time |
| // position |
| // is nearly equal to the recording start time. |
| if (mPlayStatus == PLAY_STATUS_PAUSED |
| && getCurrentPositionMs() - mRecordStartTimeMs |
| < RECORDING_BOUNDARY_THRESHOLD) { |
| TimeShiftManager.this.play(); |
| } |
| } |
| }); |
| } |
| |
| void onAvailabilityChanged() { |
| boolean newAvailable = mTvView.isTimeShiftAvailable(); |
| if (mAvailable == newAvailable) { |
| return; |
| } |
| mAvailable = newAvailable; |
| // Do not send the notifications while the availability is changing, |
| // because the variables are in the intermediate state. |
| // For example, the current program can be null. |
| mNotificationEnabled = false; |
| mDisplayedPlaySpeed = PLAY_SPEED_1X; |
| mPlaybackSpeed = 1; |
| mPlayDirection = PLAY_DIRECTION_FORWARD; |
| mHandler.removeMessages(MSG_GET_CURRENT_POSITION); |
| |
| if (mAvailable) { |
| mAvailablityChangedTimeMs = System.currentTimeMillis(); |
| mIsPlayOffsetChanged = false; |
| mRecordStartTimeMs = mAvailablityChangedTimeMs; |
| mRecordEndTimeMs = CURRENT_TIME; |
| // When the media availability message has come. |
| mPlayController.setPlayStatus(PLAY_STATUS_PLAYING); |
| mHandler.sendEmptyMessageDelayed( |
| MSG_GET_CURRENT_POSITION, REQUEST_CURRENT_POSITION_INTERVAL); |
| } else { |
| mAvailablityChangedTimeMs = INVALID_TIME; |
| mIsPlayOffsetChanged = false; |
| mRecordStartTimeMs = INVALID_TIME; |
| mRecordEndTimeMs = INVALID_TIME; |
| // When the tune command is sent. |
| mPlayController.setPlayStatus(PLAY_STATUS_PAUSED); |
| } |
| TimeShiftManager.this.onAvailabilityChanged(); |
| mNotificationEnabled = true; |
| } |
| |
| void handleGetCurrentPosition() { |
| if (mIsPlayOffsetChanged) { |
| long currentTimeMs = |
| mRecordEndTimeMs == CURRENT_TIME |
| ? System.currentTimeMillis() |
| : mRecordEndTimeMs; |
| long currentPositionMs = |
| Math.max( |
| Math.min(mTvView.timeShiftGetCurrentPositionMs(), currentTimeMs), |
| mRecordStartTimeMs); |
| boolean isCurrentTime = |
| currentTimeMs - currentPositionMs < RECORDING_BOUNDARY_THRESHOLD; |
| long newCurrentPositionMs; |
| if (isCurrentTime && isForwarding()) { |
| // It's playing forward and the current playing position reached |
| // the current system time. i.e. The live stream is played. |
| // Therefore no need to call TvView.timeShiftGetCurrentPositionMs |
| // any more. |
| newCurrentPositionMs = currentTimeMs; |
| mIsPlayOffsetChanged = false; |
| if (mDisplayedPlaySpeed > PLAY_SPEED_1X) { |
| TimeShiftManager.this.play(); |
| } |
| } else { |
| newCurrentPositionMs = currentPositionMs; |
| boolean isRecordStartTime = |
| currentPositionMs - mRecordStartTimeMs < RECORDING_BOUNDARY_THRESHOLD; |
| if (isRecordStartTime && isRewinding()) { |
| TimeShiftManager.this.play(); |
| } |
| } |
| setCurrentPositionMs(newCurrentPositionMs); |
| } else { |
| setCurrentPositionMs(System.currentTimeMillis()); |
| TimeShiftManager.this.onCurrentPositionChanged(); |
| } |
| // Need to send message here just in case there is no or invalid response |
| // for the current time position request from TIS. |
| mHandler.sendEmptyMessageDelayed( |
| MSG_GET_CURRENT_POSITION, REQUEST_CURRENT_POSITION_INTERVAL); |
| } |
| |
| void play() { |
| mDisplayedPlaySpeed = PLAY_SPEED_1X; |
| mPlaybackSpeed = 1; |
| mPlayDirection = PLAY_DIRECTION_FORWARD; |
| mTvView.timeShiftPlay(); |
| setPlayStatus(PLAY_STATUS_PLAYING); |
| } |
| |
| void pause() { |
| mDisplayedPlaySpeed = PLAY_SPEED_1X; |
| mPlaybackSpeed = 1; |
| mTvView.timeShiftPause(); |
| setPlayStatus(PLAY_STATUS_PAUSED); |
| mIsPlayOffsetChanged = true; |
| } |
| |
| void togglePlayPause() { |
| if (mPlayStatus == PLAY_STATUS_PAUSED) { |
| play(); |
| mTracker.sendTimeShiftAction(TIME_SHIFT_ACTION_ID_PLAY); |
| } else { |
| pause(); |
| mTracker.sendTimeShiftAction(TIME_SHIFT_ACTION_ID_PAUSE); |
| } |
| } |
| |
| void rewind() { |
| if (mPlayDirection == PLAY_DIRECTION_BACKWARD) { |
| increaseDisplayedPlaySpeed(); |
| } else { |
| mDisplayedPlaySpeed = PLAY_SPEED_2X; |
| } |
| mPlayDirection = PLAY_DIRECTION_BACKWARD; |
| mPlaybackSpeed = getPlaybackSpeed(); |
| mTvView.timeShiftRewind(mPlaybackSpeed); |
| setPlayStatus(PLAY_STATUS_PLAYING); |
| mIsPlayOffsetChanged = true; |
| } |
| |
| void fastForward() { |
| if (mPlayDirection == PLAY_DIRECTION_FORWARD) { |
| increaseDisplayedPlaySpeed(); |
| } else { |
| mDisplayedPlaySpeed = PLAY_SPEED_2X; |
| } |
| mPlayDirection = PLAY_DIRECTION_FORWARD; |
| mPlaybackSpeed = getPlaybackSpeed(); |
| mTvView.timeShiftFastForward(mPlaybackSpeed); |
| setPlayStatus(PLAY_STATUS_PLAYING); |
| mIsPlayOffsetChanged = true; |
| } |
| |
| /** Moves to the specified time. */ |
| void seekTo(long timeMs) { |
| mTvView.timeShiftSeekTo( |
| Math.min( |
| mRecordEndTimeMs == CURRENT_TIME |
| ? System.currentTimeMillis() |
| : mRecordEndTimeMs, |
| Math.max(mRecordStartTimeMs, timeMs))); |
| mIsPlayOffsetChanged = true; |
| } |
| |
| void onCurrentProgramChanged() { |
| // Update playback speed |
| if (mDisplayedPlaySpeed == PLAY_SPEED_1X) { |
| return; |
| } |
| int playbackSpeed = getPlaybackSpeed(); |
| if (playbackSpeed != mPlaybackSpeed) { |
| mPlaybackSpeed = playbackSpeed; |
| if (mPlayDirection == PLAY_DIRECTION_FORWARD) { |
| mTvView.timeShiftFastForward(mPlaybackSpeed); |
| } else { |
| mTvView.timeShiftRewind(mPlaybackSpeed); |
| } |
| } |
| } |
| |
| @SuppressLint("SwitchIntDef") |
| private void increaseDisplayedPlaySpeed() { |
| switch (mDisplayedPlaySpeed) { |
| case PLAY_SPEED_1X: |
| mDisplayedPlaySpeed = PLAY_SPEED_2X; |
| break; |
| case PLAY_SPEED_2X: |
| mDisplayedPlaySpeed = PLAY_SPEED_3X; |
| break; |
| case PLAY_SPEED_3X: |
| mDisplayedPlaySpeed = PLAY_SPEED_4X; |
| break; |
| case PLAY_SPEED_4X: |
| mDisplayedPlaySpeed = PLAY_SPEED_5X; |
| break; |
| } |
| } |
| |
| private void setPlayStatus(@PlayStatus int status) { |
| mPlayStatus = status; |
| TimeShiftManager.this.onPlayStatusChanged(status); |
| } |
| |
| boolean isForwarding() { |
| return mPlayStatus == PLAY_STATUS_PLAYING && mPlayDirection == PLAY_DIRECTION_FORWARD; |
| } |
| |
| private boolean isRewinding() { |
| return mPlayStatus == PLAY_STATUS_PLAYING && mPlayDirection == PLAY_DIRECTION_BACKWARD; |
| } |
| |
| Channel getCurrentChannel() { |
| return mTvView.getCurrentChannel(); |
| } |
| } |
| |
| private class ProgramManager { |
| private final ProgramDataManager mProgramDataManager; |
| private Channel mChannel; |
| private final List<Program> mPrograms = new ArrayList<>(); |
| private final Queue<Range<Long>> mProgramLoadQueue = new LinkedList<>(); |
| private LoadProgramsForCurrentChannelTask mProgramLoadTask = null; |
| private int mEmptyFetchCount = 0; |
| |
| ProgramManager(ProgramDataManager programDataManager) { |
| mProgramDataManager = programDataManager; |
| } |
| |
| void onAvailabilityChanged(boolean available, Channel channel, long currentPositionMs) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "onAvailabilityChanged(" |
| + available |
| + "+," |
| + channel |
| + ", " |
| + currentPositionMs |
| + ")"); |
| } |
| |
| mProgramLoadQueue.clear(); |
| if (mProgramLoadTask != null) { |
| mProgramLoadTask.cancel(true); |
| } |
| mHandler.removeMessages(MSG_PREFETCH_PROGRAM); |
| mPrograms.clear(); |
| mEmptyFetchCount = 0; |
| mChannel = channel; |
| if (channel == null || channel.isPassthrough() || currentPositionMs == INVALID_TIME) { |
| return; |
| } |
| if (available) { |
| Program program = mProgramDataManager.getCurrentProgram(channel.getId()); |
| long prefetchStartTimeMs; |
| if (program != null) { |
| mPrograms.add(program); |
| prefetchStartTimeMs = program.getEndTimeUtcMillis(); |
| } else { |
| prefetchStartTimeMs = |
| Utils.floorTime(currentPositionMs, MAX_DUMMY_PROGRAM_DURATION); |
| } |
| // Create dummy program |
| mPrograms.addAll( |
| createDummyPrograms( |
| prefetchStartTimeMs, |
| currentPositionMs + PREFETCH_DURATION_FOR_NEXT)); |
| schedulePrefetchPrograms(); |
| TimeShiftManager.this.onProgramInfoChanged(); |
| } |
| } |
| |
| void onRecordTimeRangeChanged(long startTimeMs, long endTimeMs) { |
| if (mChannel == null || mChannel.isPassthrough()) { |
| return; |
| } |
| if (endTimeMs == CURRENT_TIME) { |
| endTimeMs = System.currentTimeMillis(); |
| } |
| |
| long fetchStartTimeMs = Utils.floorTime(startTimeMs, MAX_DUMMY_PROGRAM_DURATION); |
| long fetchEndTimeMs = |
| Utils.ceilTime( |
| endTimeMs + PREFETCH_DURATION_FOR_NEXT, MAX_DUMMY_PROGRAM_DURATION); |
| removeOutdatedPrograms(fetchStartTimeMs); |
| boolean needToLoad = addDummyPrograms(fetchStartTimeMs, fetchEndTimeMs); |
| if (needToLoad) { |
| Range<Long> period = Range.create(fetchStartTimeMs, fetchEndTimeMs); |
| mProgramLoadQueue.add(period); |
| startTaskIfNeeded(); |
| } |
| } |
| |
| private void startTaskIfNeeded() { |
| if (mProgramLoadQueue.isEmpty()) { |
| return; |
| } |
| if (mProgramLoadTask == null || mProgramLoadTask.isCancelled()) { |
| startNext(); |
| } else { |
| // Remove pending task fully satisfied by the current |
| Range<Long> current = mProgramLoadTask.getPeriod(); |
| Iterator<Range<Long>> i = mProgramLoadQueue.iterator(); |
| while (i.hasNext()) { |
| Range<Long> r = i.next(); |
| if (current.contains(r)) { |
| i.remove(); |
| } |
| } |
| } |
| } |
| |
| private void startNext() { |
| mProgramLoadTask = null; |
| if (mProgramLoadQueue.isEmpty()) { |
| return; |
| } |
| |
| Range<Long> next = mProgramLoadQueue.poll(); |
| // Extend next to include any overlapping Ranges. |
| Iterator<Range<Long>> i = mProgramLoadQueue.iterator(); |
| while (i.hasNext()) { |
| Range<Long> r = i.next(); |
| if (next.contains(r.getLower()) || next.contains(r.getUpper())) { |
| i.remove(); |
| next = next.extend(r); |
| } |
| } |
| if (mChannel != null) { |
| mProgramLoadTask = new LoadProgramsForCurrentChannelTask(next); |
| mProgramLoadTask.executeOnDbThread(); |
| } |
| } |
| |
| void addDummyProgramsAt(long timeMs) { |
| addDummyPrograms(timeMs, timeMs + PREFETCH_DURATION_FOR_NEXT); |
| } |
| |
| private boolean addDummyPrograms(Range<Long> period) { |
| return addDummyPrograms(period.getLower(), period.getUpper()); |
| } |
| |
| private boolean addDummyPrograms(long startTimeMs, long endTimeMs) { |
| boolean added = false; |
| if (mPrograms.isEmpty()) { |
| // Insert dummy program. |
| mPrograms.addAll(createDummyPrograms(startTimeMs, endTimeMs)); |
| return true; |
| } |
| // Insert dummy program to the head of the list if needed. |
| Program firstProgram = mPrograms.get(0); |
| if (startTimeMs < firstProgram.getStartTimeUtcMillis()) { |
| if (!firstProgram.isValid()) { |
| // Already the firstProgram is dummy. |
| mPrograms.remove(0); |
| mPrograms.addAll( |
| 0, |
| createDummyPrograms(startTimeMs, firstProgram.getEndTimeUtcMillis())); |
| } else { |
| mPrograms.addAll( |
| 0, |
| createDummyPrograms(startTimeMs, firstProgram.getStartTimeUtcMillis())); |
| } |
| added = true; |
| } |
| // Insert dummy program to the tail of the list if needed. |
| Program lastProgram = mPrograms.get(mPrograms.size() - 1); |
| if (endTimeMs > lastProgram.getEndTimeUtcMillis()) { |
| if (!lastProgram.isValid()) { |
| // Already the lastProgram is dummy. |
| mPrograms.remove(mPrograms.size() - 1); |
| mPrograms.addAll( |
| createDummyPrograms(lastProgram.getStartTimeUtcMillis(), endTimeMs)); |
| } else { |
| mPrograms.addAll( |
| createDummyPrograms(lastProgram.getEndTimeUtcMillis(), endTimeMs)); |
| } |
| added = true; |
| } |
| // Insert dummy programs if the holes exist in the list. |
| for (int i = 1; i < mPrograms.size(); ++i) { |
| long endOfPrevious = mPrograms.get(i - 1).getEndTimeUtcMillis(); |
| long startOfCurrent = mPrograms.get(i).getStartTimeUtcMillis(); |
| if (startOfCurrent > endOfPrevious) { |
| List<Program> dummyPrograms = |
| createDummyPrograms(endOfPrevious, startOfCurrent); |
| mPrograms.addAll(i, dummyPrograms); |
| i += dummyPrograms.size(); |
| added = true; |
| } |
| } |
| return added; |
| } |
| |
| private void removeOutdatedPrograms(long startTimeMs) { |
| while (mPrograms.size() > 0 && mPrograms.get(0).getEndTimeUtcMillis() <= startTimeMs) { |
| mPrograms.remove(0); |
| } |
| } |
| |
| private void removeDummyPrograms() { |
| for (Iterator<Program> it = mPrograms.listIterator(); it.hasNext(); ) { |
| if (!it.next().isValid()) { |
| it.remove(); |
| } |
| } |
| } |
| |
| private void removeOverlappedPrograms(List<Program> loadedPrograms) { |
| if (mPrograms.size() == 0) { |
| return; |
| } |
| Program program = mPrograms.get(0); |
| for (int i = 0, j = 0; i < mPrograms.size() && j < loadedPrograms.size(); ++j) { |
| Program loadedProgram = loadedPrograms.get(j); |
| // Skip previous programs. |
| while (program.getEndTimeUtcMillis() <= loadedProgram.getStartTimeUtcMillis()) { |
| // Reached end of mPrograms. |
| if (++i == mPrograms.size()) { |
| return; |
| } |
| program = mPrograms.get(i); |
| } |
| // Remove overlapped programs. |
| while (program.getStartTimeUtcMillis() < loadedProgram.getEndTimeUtcMillis() |
| && program.getEndTimeUtcMillis() > loadedProgram.getStartTimeUtcMillis()) { |
| mPrograms.remove(i); |
| if (i >= mPrograms.size()) { |
| break; |
| } |
| program = mPrograms.get(i); |
| } |
| } |
| } |
| |
| // Returns a list of dummy programs. |
| // The maximum duration of a dummy program is {@link MAX_DUMMY_PROGRAM_DURATION}. |
| // So if the duration ({@code endTimeMs}-{@code startTimeMs}) is greater than the duration, |
| // we need to create multiple dummy programs. |
| // The reason of the limitation of the duration is because we want the trick play viewer |
| // to show the time-line duration of {@link MAX_DUMMY_PROGRAM_DURATION} at most |
| // for a dummy program. |
| private List<Program> createDummyPrograms(long startTimeMs, long endTimeMs) { |
| SoftPreconditions.checkArgument( |
| endTimeMs - startTimeMs <= TWO_WEEKS_MS, |
| TAG, |
| "createDummyProgram: long duration of dummy programs are requested ( %s , %s)", |
| Utils.toTimeString(startTimeMs), |
| Utils.toTimeString(endTimeMs)); |
| if (startTimeMs >= endTimeMs) { |
| return Collections.emptyList(); |
| } |
| List<Program> programs = new ArrayList<>(); |
| long start = startTimeMs; |
| long end = Utils.ceilTime(startTimeMs, MAX_DUMMY_PROGRAM_DURATION); |
| while (end < endTimeMs) { |
| programs.add( |
| new Program.Builder() |
| .setStartTimeUtcMillis(start) |
| .setEndTimeUtcMillis(end) |
| .build()); |
| start = end; |
| end += MAX_DUMMY_PROGRAM_DURATION; |
| } |
| programs.add( |
| new Program.Builder() |
| .setStartTimeUtcMillis(start) |
| .setEndTimeUtcMillis(endTimeMs) |
| .build()); |
| return programs; |
| } |
| |
| Program getProgramAt(long timeMs) { |
| return getProgramAt(timeMs, 0, mPrograms.size() - 1); |
| } |
| |
| private Program getProgramAt(long timeMs, int start, int end) { |
| if (start > end) { |
| return null; |
| } |
| int mid = (start + end) / 2; |
| Program program = mPrograms.get(mid); |
| if (program.getStartTimeUtcMillis() > timeMs) { |
| return getProgramAt(timeMs, start, mid - 1); |
| } else if (program.getEndTimeUtcMillis() <= timeMs) { |
| return getProgramAt(timeMs, mid + 1, end); |
| } else { |
| return program; |
| } |
| } |
| |
| private long getOldestProgramStartTime() { |
| if (mPrograms.isEmpty()) { |
| return INVALID_TIME; |
| } |
| return mPrograms.get(0).getStartTimeUtcMillis(); |
| } |
| |
| private Program getLastValidProgram() { |
| for (int i = mPrograms.size() - 1; i >= 0; --i) { |
| Program program = mPrograms.get(i); |
| if (program.isValid()) { |
| return program; |
| } |
| } |
| return null; |
| } |
| |
| private void schedulePrefetchPrograms() { |
| if (DEBUG) Log.d(TAG, "Scheduling prefetching programs."); |
| if (mHandler.hasMessages(MSG_PREFETCH_PROGRAM)) { |
| return; |
| } |
| Program lastValidProgram = getLastValidProgram(); |
| if (DEBUG) Log.d(TAG, "Last valid program = " + lastValidProgram); |
| final long delay; |
| if (lastValidProgram != null) { |
| delay = |
| lastValidProgram.getEndTimeUtcMillis() |
| - PREFETCH_TIME_OFFSET_FROM_PROGRAM_END |
| - System.currentTimeMillis(); |
| } else { |
| // Since there might not be any program data delay the retry 5 seconds, |
| // then 30 seconds then 5 minutes |
| switch (mEmptyFetchCount) { |
| case 0: |
| delay = 0; |
| break; |
| case 1: |
| delay = TimeUnit.SECONDS.toMillis(5); |
| break; |
| case 2: |
| delay = TimeUnit.SECONDS.toMillis(30); |
| break; |
| default: |
| delay = TimeUnit.MINUTES.toMillis(5); |
| break; |
| } |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "No last valid program. Already tried " + mEmptyFetchCount + " times"); |
| } |
| } |
| mHandler.sendEmptyMessageDelayed(MSG_PREFETCH_PROGRAM, delay); |
| if (DEBUG) Log.d(TAG, "Scheduling with " + delay + "(ms) delays."); |
| } |
| |
| // Prefetch programs within PREFETCH_DURATION_FOR_NEXT from now. |
| private void prefetchPrograms() { |
| long startTimeMs; |
| Program lastValidProgram = getLastValidProgram(); |
| if (lastValidProgram == null) { |
| startTimeMs = System.currentTimeMillis(); |
| } else { |
| startTimeMs = lastValidProgram.getEndTimeUtcMillis(); |
| } |
| long endTimeMs = System.currentTimeMillis() + PREFETCH_DURATION_FOR_NEXT; |
| if (startTimeMs <= endTimeMs) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Prefetch task starts: {startTime=" |
| + Utils.toTimeString(startTimeMs) |
| + ", endTime=" |
| + Utils.toTimeString(endTimeMs) |
| + "}"); |
| } |
| mProgramLoadQueue.add(Range.create(startTimeMs, endTimeMs)); |
| } |
| startTaskIfNeeded(); |
| } |
| |
| private class LoadProgramsForCurrentChannelTask |
| extends AsyncDbTask.LoadProgramsForChannelTask { |
| |
| LoadProgramsForCurrentChannelTask(Range<Long> period) { |
| super( |
| TvSingletons.getSingletons(mContext).getDbExecutor(), |
| mContext, |
| mChannel.getId(), |
| period); |
| } |
| |
| @Override |
| protected void onPostExecute(List<Program> programs) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Programs are loaded {channelId=" |
| + mChannelId |
| + ", from=" |
| + Utils.toTimeString(mPeriod.getLower()) |
| + ", to=" |
| + Utils.toTimeString(mPeriod.getUpper()) |
| + "}"); |
| } |
| // remove pending tasks that are fully satisfied by this query. |
| Iterator<Range<Long>> it = mProgramLoadQueue.iterator(); |
| while (it.hasNext()) { |
| Range<Long> r = it.next(); |
| if (mPeriod.contains(r)) { |
| it.remove(); |
| } |
| } |
| if (programs == null || programs.isEmpty()) { |
| mEmptyFetchCount++; |
| if (addDummyPrograms(mPeriod)) { |
| TimeShiftManager.this.onProgramInfoChanged(); |
| } |
| schedulePrefetchPrograms(); |
| startNextLoadingIfNeeded(); |
| return; |
| } |
| mEmptyFetchCount = 0; |
| if (!mPrograms.isEmpty()) { |
| removeDummyPrograms(); |
| removeOverlappedPrograms(programs); |
| Program loadedProgram = programs.get(0); |
| for (int i = 0; i < mPrograms.size() && !programs.isEmpty(); ++i) { |
| Program program = mPrograms.get(i); |
| while (program.getStartTimeUtcMillis() |
| > loadedProgram.getStartTimeUtcMillis()) { |
| mPrograms.add(i++, loadedProgram); |
| programs.remove(0); |
| if (programs.isEmpty()) { |
| break; |
| } |
| loadedProgram = programs.get(0); |
| } |
| } |
| } |
| mPrograms.addAll(programs); |
| addDummyPrograms(mPeriod); |
| TimeShiftManager.this.onProgramInfoChanged(); |
| schedulePrefetchPrograms(); |
| startNextLoadingIfNeeded(); |
| } |
| |
| @Override |
| protected void onCancelled(List<Program> programs) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Program loading has been canceled {channelId=" |
| + (mChannel == null ? "null" : mChannelId) |
| + ", from=" |
| + Utils.toTimeString(mPeriod.getLower()) |
| + ", to=" |
| + Utils.toTimeString(mPeriod.getUpper()) |
| + "}"); |
| } |
| startNextLoadingIfNeeded(); |
| } |
| |
| private void startNextLoadingIfNeeded() { |
| if (mProgramLoadTask == this) { |
| mProgramLoadTask = null; |
| } |
| // Need to post to handler, because the task is still running. |
| mHandler.post(ProgramManager.this::startTaskIfNeeded); |
| } |
| |
| boolean overlaps(Queue<Range<Long>> programLoadQueue) { |
| for (Range<Long> r : programLoadQueue) { |
| if (mPeriod.contains(r.getLower()) || mPeriod.contains(r.getUpper())) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| final class CurrentPositionMediator { |
| long mCurrentPositionMs; |
| long mSeekRequestTimeMs; |
| |
| void initialize(long timeMs) { |
| mSeekRequestTimeMs = INVALID_TIME; |
| mCurrentPositionMs = timeMs; |
| if (timeMs != INVALID_TIME) { |
| TimeShiftManager.this.onCurrentPositionChanged(); |
| } |
| } |
| |
| void onSeekRequested(long seekTimeMs) { |
| mSeekRequestTimeMs = System.currentTimeMillis(); |
| mCurrentPositionMs = seekTimeMs; |
| TimeShiftManager.this.onCurrentPositionChanged(); |
| } |
| |
| void onCurrentPositionChanged(long currentPositionMs) { |
| if (mSeekRequestTimeMs == INVALID_TIME) { |
| mCurrentPositionMs = currentPositionMs; |
| TimeShiftManager.this.onCurrentPositionChanged(); |
| return; |
| } |
| long currentTimeMs = System.currentTimeMillis(); |
| boolean isValid = Math.abs(currentPositionMs - mCurrentPositionMs) < REQUEST_TIMEOUT_MS; |
| boolean isTimeout = currentTimeMs > mSeekRequestTimeMs + REQUEST_TIMEOUT_MS; |
| if (isValid || isTimeout) { |
| initialize(currentPositionMs); |
| } else { |
| if (getPlayStatus() == PLAY_STATUS_PLAYING) { |
| if (getPlayDirection() == PLAY_DIRECTION_FORWARD) { |
| mCurrentPositionMs += |
| (currentTimeMs - mSeekRequestTimeMs) * getPlaybackSpeed(); |
| } else { |
| mCurrentPositionMs -= |
| (currentTimeMs - mSeekRequestTimeMs) * getPlaybackSpeed(); |
| } |
| } |
| TimeShiftManager.this.onCurrentPositionChanged(); |
| } |
| } |
| } |
| |
| /** The listener used to receive the events by the time-shift manager */ |
| public interface Listener { |
| /** |
| * Called when the availability of the time-shift for the current channel has been changed. |
| * If the time shift is available, {@link TimeShiftManager#getRecordStartTimeMs} should |
| * return the valid time. |
| */ |
| void onAvailabilityChanged(); |
| |
| /** |
| * Called when the play status is changed between {@link #PLAY_STATUS_PLAYING} and {@link |
| * #PLAY_STATUS_PAUSED} |
| * |
| * @param status The new play state. |
| */ |
| void onPlayStatusChanged(int status); |
| |
| /** Called when the recordStartTime has been changed. */ |
| void onRecordTimeRangeChanged(); |
| |
| /** Called when the current position is changed. */ |
| void onCurrentPositionChanged(); |
| |
| /** Called when the program information is updated. */ |
| void onProgramInfoChanged(); |
| |
| /** Called when an action becomes enabled or disabled. */ |
| void onActionEnabledChanged(@TimeShiftActionId int actionId, boolean enabled); |
| } |
| |
| private static class TimeShiftHandler extends WeakHandler<TimeShiftManager> { |
| TimeShiftHandler(TimeShiftManager ref) { |
| super(ref); |
| } |
| |
| @Override |
| public void handleMessage(Message msg, @NonNull TimeShiftManager timeShiftManager) { |
| switch (msg.what) { |
| case MSG_GET_CURRENT_POSITION: |
| timeShiftManager.mPlayController.handleGetCurrentPosition(); |
| break; |
| case MSG_PREFETCH_PROGRAM: |
| timeShiftManager.mProgramManager.prefetchPrograms(); |
| break; |
| } |
| } |
| } |
| } |