/* * 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.ProgramDataManager; import com.android.tv.data.ProgramImpl; import com.android.tv.data.api.Channel; import com.android.tv.data.api.Program; 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 TV app. 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_PLACEHOLDER_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 TV app. 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.addPlaceholderProgramsAt(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. * *

If the program is a placeholder 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 mPrograms = new ArrayList<>(); private final Queue> 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_PLACEHOLDER_PROGRAM_DURATION); } // Create placeholder program mPrograms.addAll( createPlaceholderPrograms( 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_PLACEHOLDER_PROGRAM_DURATION); long fetchEndTimeMs = Utils.ceilTime( endTimeMs + PREFETCH_DURATION_FOR_NEXT, MAX_PLACEHOLDER_PROGRAM_DURATION); removeOutdatedPrograms(fetchStartTimeMs); boolean needToLoad = addPlaceholderPrograms(fetchStartTimeMs, fetchEndTimeMs); if (needToLoad) { Range 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 current = mProgramLoadTask.getPeriod(); Iterator> i = mProgramLoadQueue.iterator(); while (i.hasNext()) { Range r = i.next(); if (current.contains(r)) { i.remove(); } } } } private void startNext() { mProgramLoadTask = null; if (mProgramLoadQueue.isEmpty()) { return; } Range next = mProgramLoadQueue.poll(); // Extend next to include any overlapping Ranges. Iterator> i = mProgramLoadQueue.iterator(); while (i.hasNext()) { Range 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 addPlaceholderProgramsAt(long timeMs) { addPlaceholderPrograms(timeMs, timeMs + PREFETCH_DURATION_FOR_NEXT); } private boolean addPlaceholderPrograms(Range period) { return addPlaceholderPrograms(period.getLower(), period.getUpper()); } private boolean addPlaceholderPrograms(long startTimeMs, long endTimeMs) { boolean added = false; if (mPrograms.isEmpty()) { // Insert placeholder program. mPrograms.addAll(createPlaceholderPrograms(startTimeMs, endTimeMs)); return true; } // Insert placeholder 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 a placeholder. mPrograms.remove(0); mPrograms.addAll( 0, createPlaceholderPrograms(startTimeMs, firstProgram.getEndTimeUtcMillis())); } else { mPrograms.addAll( 0, createPlaceholderPrograms(startTimeMs, firstProgram.getStartTimeUtcMillis())); } added = true; } // Insert placeholder 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 a placeholder. mPrograms.remove(mPrograms.size() - 1); mPrograms.addAll( createPlaceholderPrograms(lastProgram.getStartTimeUtcMillis(), endTimeMs)); } else { mPrograms.addAll( createPlaceholderPrograms(lastProgram.getEndTimeUtcMillis(), endTimeMs)); } added = true; } // Insert placeholder 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 placeholderPrograms = createPlaceholderPrograms(endOfPrevious, startOfCurrent); mPrograms.addAll(i, placeholderPrograms); i += placeholderPrograms.size(); added = true; } } return added; } private void removeOutdatedPrograms(long startTimeMs) { while (mPrograms.size() > 0 && mPrograms.get(0).getEndTimeUtcMillis() <= startTimeMs) { mPrograms.remove(0); } } private void removePlaceholderPrograms() { for (Iterator it = mPrograms.listIterator(); it.hasNext(); ) { if (!it.next().isValid()) { it.remove(); } } } private void removeOverlappedPrograms(List 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 placeholder programs. // The maximum duration of a placeholder program is {@link MAX_PLACEHOLDER_PROGRAM_DURATION}. // So if the duration ({@code endTimeMs}-{@code startTimeMs}) is greater than the duration, // we need to create multiple placeholder 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_PLACEHOLDER_PROGRAM_DURATION} at most // for a placeholder program. private List createPlaceholderPrograms(long startTimeMs, long endTimeMs) { SoftPreconditions.checkArgument( endTimeMs - startTimeMs <= TWO_WEEKS_MS, TAG, "createPlaceholderProgram: long duration of placeholder programs are requested ( %s , %s)", Utils.toTimeString(startTimeMs), Utils.toTimeString(endTimeMs)); if (startTimeMs >= endTimeMs) { return Collections.emptyList(); } List programs = new ArrayList<>(); long start = startTimeMs; long end = Utils.ceilTime(startTimeMs, MAX_PLACEHOLDER_PROGRAM_DURATION); while (end < endTimeMs) { programs.add( new ProgramImpl.Builder() .setStartTimeUtcMillis(start) .setEndTimeUtcMillis(end) .build()); start = end; end += MAX_PLACEHOLDER_PROGRAM_DURATION; } programs.add( new ProgramImpl.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 period) { super( TvSingletons.getSingletons(mContext).getDbExecutor(), mContext, mChannel.getId(), period); } @Override protected void onPostExecute(List 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> it = mProgramLoadQueue.iterator(); while (it.hasNext()) { Range r = it.next(); if (mPeriod.contains(r)) { it.remove(); } } if (programs == null || programs.isEmpty()) { mEmptyFetchCount++; if (addPlaceholderPrograms(mPeriod)) { TimeShiftManager.this.onProgramInfoChanged(); } schedulePrefetchPrograms(); startNextLoadingIfNeeded(); return; } mEmptyFetchCount = 0; if (!mPrograms.isEmpty()) { removePlaceholderPrograms(); 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); addPlaceholderPrograms(mPeriod); TimeShiftManager.this.onProgramInfoChanged(); schedulePrefetchPrograms(); startNextLoadingIfNeeded(); } @Override protected void onCancelled(List 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> programLoadQueue) { for (Range 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 { 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; } } } }