blob: 790fc8dca81c531aba2e80946a44013a376994ba [file] [log] [blame]
/*
* 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.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;
}
}
}
}