/*
* Copyright 2016 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.google.android.media.tv.companionlibrary;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.media.PlaybackParams;
import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvInputManager;
import android.media.tv.TvInputService;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.support.annotation.RequiresApi;
import android.util.Log;
import android.util.LongSparseArray;
import android.view.Surface;
import com.google.android.media.tv.companionlibrary.model.Advertisement;
import com.google.android.media.tv.companionlibrary.model.Channel;
import com.google.android.media.tv.companionlibrary.model.Program;
import com.google.android.media.tv.companionlibrary.model.RecordedProgram;
import com.google.android.media.tv.companionlibrary.utils.TvContractUtils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/**
* The BaseTvInputService provides helper methods to make it easier to create a
* {@link TvInputService} with built-in methods for content blocking and pulling the current program
* from the Electronic Programming Guide.
*/
public abstract class BaseTvInputService extends TvInputService {
private static final String TAG = BaseTvInputService.class.getSimpleName();
private static final boolean DEBUG = false;
/**
* Used for interacting with {@link SharedPreferences}.
* @hide
*/
public static final String PREFERENCES_FILE_KEY =
"com.google.android.media.tv.companionlibrary";
/**
* Base key string used to identifying last played ad times for a channel
* TODO This key will be shared by multiple Sessions (e.g. PIP)
* @hide
*/
public static final String SHARED_PREFERENCES_KEY_LAST_CHANNEL_AD_PLAY =
"last_program_ad_time_ms";
// For database calls
private static HandlerThread mDbHandlerThread;
// Map of channel {@link TvContract.Channels#_ID} to Channel objects
private static LongSparseArray<Channel> mChannelMap;
private static ContentResolver mContentResolver;
private static ContentObserver mChannelObserver;
// For content ratings
private static final List<Session> mSessions = new ArrayList<>();
private final BroadcastReceiver mParentalControlsBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
for (Session session : mSessions) {
TvInputManager manager =
(TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
if (!manager.isParentalControlsEnabled()) {
session.onUnblockContent(null);
} else {
session.checkCurrentProgramContent();
}
}
}
};
@Override
public void onCreate() {
super.onCreate();
// Create background thread
mDbHandlerThread = new HandlerThread(getClass().getSimpleName());
mDbHandlerThread.start();
// Initialize the channel map and set observer for changes
mContentResolver = BaseTvInputService.this.getContentResolver();
updateChannelMap();
mChannelObserver = new ContentObserver(new Handler(mDbHandlerThread.getLooper())) {
@Override
public void onChange(boolean selfChange) {
updateChannelMap();
}
};
mContentResolver.registerContentObserver(TvContract.Channels.CONTENT_URI, true,
mChannelObserver);
// Setup our BroadcastReceiver
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(TvInputManager.ACTION_BLOCKED_RATINGS_CHANGED);
intentFilter.addAction(TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED);
registerReceiver(mParentalControlsBroadcastReceiver, intentFilter);
}
private void updateChannelMap() {
ComponentName component = new ComponentName(BaseTvInputService.this.getPackageName(),
BaseTvInputService.this.getClass().getName());
String inputId = TvContract.buildInputId(component);
mChannelMap = TvContractUtils.buildChannelMap(mContentResolver, inputId);
}
/**
* Adds the Session to the list of currently available sessions.
* @param session The newly created session.
* @return The session that was created.
*/
public Session sessionCreated(Session session) {
mSessions.add(session);
return session;
}
@Override
public void onDestroy() {
super.onDestroy();
unregisterReceiver(mParentalControlsBroadcastReceiver);
mContentResolver.unregisterContentObserver(mChannelObserver);
mDbHandlerThread.quit();
mDbHandlerThread = null;
}
/**
* A {@link BaseTvInputService.Session} is called when a user tunes to channel provided by
* this {@link BaseTvInputService}.
*/
public static abstract class Session extends TvInputService.Session implements Handler.Callback {
private static final int MSG_PLAY_CONTENT = 1000;
private static final int MSG_PLAY_AD = 1001;
private static final int MSG_PLAY_RECORDED_CONTENT = 1002;
/** Minimum difference between playback time and system time in order for playback
* to be considered non-live (timeshifted). */
private static final long TIME_SHIFTED_MINIMUM_DIFFERENCE_MILLIS = 3000L;
/** Buffer around current time for scheduling ads. If an ad will stop within this
* amount of time relative to the current time, it is considered past and will not load. */
private static final long PAST_AD_BUFFER_MILLIS = 2000L;
private final Context mContext;
private final TvInputManager mTvInputManager;
private Channel mCurrentChannel;
private Program mCurrentProgram;
private long mElapsedProgramTime;
private long mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
private boolean mTimeShiftIsPaused;
private boolean mNeedToCheckChannelAd;
private long mElapsedAdsTime;
private boolean mPlayingRecordedProgram;
private RecordedProgram mRecordedProgram;
private long mRecordedPlaybackStartTime = TvInputManager.TIME_SHIFT_INVALID_TIME;
private TvContentRating mLastBlockedRating;
private TvContentRating[] mCurrentContentRatingSet;
private final Set<TvContentRating> mUnblockedRatingSet = new HashSet<>();
private final Handler mDbHandler;
private final Handler mHandler;
private GetCurrentProgramRunnable mGetCurrentProgramRunnable;
private long mMinimumOnTuneAdInterval = TimeUnit.MINUTES.toMillis(5);
private AdController mAdController;
private Uri mChannelUri;
private Surface mSurface;
private float mVolume;
public Session(Context context, String inputId) {
super(context);
this.mContext = context;
mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
mLastBlockedRating = null;
mDbHandler = new Handler(mDbHandlerThread.getLooper());
mHandler = new Handler(this);
}
@Override
public void onRelease() {
mDbHandler.removeCallbacksAndMessages(null);
mHandler.removeCallbacksAndMessages(null);
releaseAdController();
mSessions.remove(this);
}
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_PLAY_CONTENT:
mCurrentProgram = (Program) msg.obj;
playCurrentContent();
return true;
case MSG_PLAY_AD:
return insertAd((Advertisement) msg.obj);
case MSG_PLAY_RECORDED_CONTENT:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mPlayingRecordedProgram = true;
mRecordedProgram = (RecordedProgram) msg.obj;
playRecordedContent();
}
return true;
}
return false;
}
@Override
public boolean onSetSurface(Surface surface) {
setTvPlayerSurface(surface);
mSurface = surface;
return true;
}
private void setTvPlayerSurface(Surface surface) {
if (getTvPlayer() != null) {
getTvPlayer().setSurface(surface);
}
}
@Override
public void onSetStreamVolume(float volume) {
setTvPlayerVolume(volume);
mVolume = volume;
}
private void setTvPlayerVolume(float volume) {
if (getTvPlayer() != null) {
getTvPlayer().setVolume(volume);
}
}
@Override
public boolean onTune(Uri channelUri) {
mNeedToCheckChannelAd = true;
notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
mChannelUri = channelUri;
long channelId = ContentUris.parseId(channelUri);
mCurrentChannel = mChannelMap.get(channelId);
mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
// Release Ads assets
releaseAdController();
mHandler.removeMessages(MSG_PLAY_AD);
if (mDbHandler != null) {
mUnblockedRatingSet.clear();
mDbHandler.removeCallbacks(mGetCurrentProgramRunnable);
mGetCurrentProgramRunnable = new GetCurrentProgramRunnable(mChannelUri);
mDbHandler.post(mGetCurrentProgramRunnable);
}
return true;
}
@Override
public void onTimeShiftPause() {
mHandler.removeMessages(MSG_PLAY_AD);
mDbHandler.removeCallbacks(mGetCurrentProgramRunnable);
mTimeShiftIsPaused = true;
if (getTvPlayer() != null) {
getTvPlayer().pause();
}
}
@Override
public void onTimeShiftResume() {
if (DEBUG) Log.d(TAG, "Resume playback of program");
mTimeShiftIsPaused = false;
if (mCurrentProgram == null) {
return;
}
if (!mPlayingRecordedProgram) {
// If currently playing program content, past ad durations must be recalculated
// based on getTvPlayer.getCurrentPosition().
mElapsedAdsTime = 0;
mElapsedProgramTime = getTvPlayer().getCurrentPosition();
long elapsedProgramTimeAdjusted = mElapsedProgramTime +
mCurrentProgram.getStartTimeUtcMillis();
if (mCurrentProgram.getInternalProviderData() != null) {
List<Advertisement> ads = mCurrentProgram.getInternalProviderData().getAds();
// First, sort the ads in time order.
TreeMap<Long, Long> scheduledAds = new TreeMap<>();
for (Advertisement ad : ads) {
scheduledAds.put(ad.getStartTimeUtcMillis(), ad.getStopTimeUtcMillis());
}
// Second, add up all ad times which should have played before the elapsed
// program time.
long programDurationPlayed = 0;
long totalDurationPlayed = 0;
for (Long adStartTime : scheduledAds.keySet()) {
programDurationPlayed += adStartTime - totalDurationPlayed;
if (programDurationPlayed < elapsedProgramTimeAdjusted) {
long adDuration = scheduledAds.get(adStartTime) - adStartTime;
mElapsedAdsTime += adDuration;
totalDurationPlayed = programDurationPlayed + adDuration;
} else {
break;
}
}
} else {
Log.w(TAG, "Failed to get program provider data for " +
mCurrentProgram.getTitle() + ". Try to do an EPG sync.");
}
mTimeShiftedPlaybackPosition = elapsedProgramTimeAdjusted + mElapsedAdsTime;
scheduleNextAd();
scheduleNextProgram();
}
if (getTvPlayer() != null) {
getTvPlayer().play();
}
// Resume and make sure media is playing at regular speed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PlaybackParams normalParams = new PlaybackParams();
normalParams.setSpeed(1);
onTimeShiftSetPlaybackParams(normalParams);
}
}
@Override
public void onTimeShiftSeekTo(long timeMs) {
if (DEBUG) Log.d(TAG, "Seeking to the position: " + timeMs);
if (mCurrentProgram == null) {
return;
}
mHandler.removeMessages(MSG_PLAY_AD);
mDbHandler.removeCallbacks(mGetCurrentProgramRunnable);
// Update our handler because we have changed the playback time.
if (getTvPlayer() != null) {
if (mPlayingRecordedProgram) {
long recordingStartTime = mCurrentProgram.getInternalProviderData()
.getRecordedProgramStartTime();
getTvPlayer().seekTo((timeMs - mRecordedPlaybackStartTime) +
(recordingStartTime - mCurrentProgram.getStartTimeUtcMillis()));
} else {
// Shortcut for switching to live playback.
if (timeMs > System.currentTimeMillis() -
TIME_SHIFTED_MINIMUM_DIFFERENCE_MILLIS) {
mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
playCurrentContent();
return;
}
mTimeShiftedPlaybackPosition = timeMs;
// Elapsed ad time and program time will need to be recalculated
// as if we just tuned to the channel at mTimeShiftPlaybackPosition.
calculateElapsedTimesFromCurrentTime();
scheduleNextAd();
scheduleNextProgram();
getTvPlayer().seekTo(mElapsedProgramTime);
onTimeShiftGetCurrentPosition();
// After adjusting necessary elapsed playback times based on new
// time shift position, content should not continue to play if previously
// in a paused state.
if (mTimeShiftIsPaused) {
onTimeShiftPause();
}
}
}
}
@RequiresApi(api = Build.VERSION_CODES.M)
@Override
public long onTimeShiftGetStartPosition() {
if (mCurrentProgram != null) {
if (mPlayingRecordedProgram) {
return mRecordedPlaybackStartTime;
} else {
return mCurrentProgram.getStartTimeUtcMillis();
}
}
return TvInputManager.TIME_SHIFT_INVALID_TIME;
}
@RequiresApi(api = Build.VERSION_CODES.M)
@Override
public long onTimeShiftGetCurrentPosition() {
if (getTvPlayer() != null && mCurrentProgram != null) {
if (mPlayingRecordedProgram) {
long recordingStartTime = mCurrentProgram.getInternalProviderData()
.getRecordedProgramStartTime();
// If time shifting somehow shifted past (before) recording start time,
// seek player back up to recording start time.
if (getTvPlayer().getCurrentPosition() < recordingStartTime -
mCurrentProgram.getStartTimeUtcMillis()) {
getTvPlayer().seekTo(recordingStartTime -
mCurrentProgram.getStartTimeUtcMillis());
getTvPlayer().pause();
}
return getTvPlayer().getCurrentPosition() - (recordingStartTime -
mCurrentProgram.getStartTimeUtcMillis()) + mRecordedPlaybackStartTime;
} else {
mElapsedProgramTime = getTvPlayer().getCurrentPosition();
mTimeShiftedPlaybackPosition = mElapsedProgramTime + mElapsedAdsTime +
mCurrentProgram.getStartTimeUtcMillis();
if (DEBUG) {
Log.d(TAG, "Time Shift Current Position");
Log.d(TAG, "Elapsed program time: " + mElapsedProgramTime);
Log.d(TAG, "Elapsed ads time: " + mElapsedAdsTime);
Log.d(TAG, "Total elapsed time: " + (mTimeShiftedPlaybackPosition -
mCurrentProgram.getStartTimeUtcMillis()));
Log.d(TAG, "Time shift difference: " + (System.currentTimeMillis() -
mTimeShiftedPlaybackPosition));
Log.d(TAG, "============================");
}
return getCurrentTime();
}
}
return TvInputManager.TIME_SHIFT_INVALID_TIME;
}
@RequiresApi(api = Build.VERSION_CODES.M)
@Override
public void onTimeShiftSetPlaybackParams(PlaybackParams params) {
if (params.getSpeed() != 1.0f) {
mHandler.removeMessages(MSG_PLAY_AD);
mDbHandler.removeCallbacks(mGetCurrentProgramRunnable);
}
if (DEBUG) {
Log.d(TAG, "Set playback speed to " + params.getSpeed());
}
if (getTvPlayer() != null) {
getTvPlayer().setPlaybackParams(params);
}
}
@RequiresApi(api = Build.VERSION_CODES.M)
@Override
public void onTimeShiftPlay(Uri recordedProgramUri) {
if (DEBUG) {
Log.d(TAG, "onTimeShiftPlay " + recordedProgramUri);
}
GetRecordedProgramRunnable getRecordedProgramRunnable =
new GetRecordedProgramRunnable(recordedProgramUri);
mDbHandler.post(getRecordedProgramRunnable);
}
/**
* This method is called when the currently playing program has been blocked by parental
* controls. Developers should release their {@link TvPlayer} immediately so unwanted
* content is not displayed.
*
* @param rating The rating for the program that was blocked.
*/
public void onBlockContent(TvContentRating rating) {
}
@Override
public void onUnblockContent(TvContentRating rating) {
// If called with null, parental controls are off.
if (rating == null) {
mUnblockedRatingSet.clear();
}
unblockContent(rating);
if (mPlayingRecordedProgram) {
playRecordedContent();
} else {
playCurrentContent();
}
}
private boolean checkCurrentProgramContent() {
mCurrentContentRatingSet = (mCurrentProgram == null
|| mCurrentProgram.getContentRatings() == null
|| mCurrentProgram.getContentRatings().length == 0) ? null :
mCurrentProgram.getContentRatings();
return blockContentIfNeeded();
}
private void playRecordedContent() {
mCurrentProgram = mRecordedProgram.toProgram();
if (mTvInputManager.isParentalControlsEnabled() && !checkCurrentProgramContent()) {
return;
}
mRecordedPlaybackStartTime = System.currentTimeMillis();
if (onPlayRecordedProgram(mRecordedProgram)) {
setTvPlayerSurface(mSurface);
setTvPlayerVolume(mVolume);
}
}
private long getCurrentTime() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
long timeShiftedDifference = System.currentTimeMillis() -
mTimeShiftedPlaybackPosition;
if (mTimeShiftedPlaybackPosition != TvInputManager.TIME_SHIFT_INVALID_TIME &&
timeShiftedDifference > TIME_SHIFTED_MINIMUM_DIFFERENCE_MILLIS) {
return mTimeShiftedPlaybackPosition;
}
}
mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
return System.currentTimeMillis();
}
private void scheduleNextProgram() {
mDbHandler.removeCallbacks(mGetCurrentProgramRunnable);
mDbHandler.postDelayed(mGetCurrentProgramRunnable,
mCurrentProgram.getEndTimeUtcMillis() - getCurrentTime());
}
private void playCurrentContent() {
if (mTvInputManager.isParentalControlsEnabled() && !checkCurrentProgramContent()) {
scheduleNextProgram();
return;
}
if (mNeedToCheckChannelAd) {
playCurrentChannel();
return;
}
if (playCurrentProgram()) {
setTvPlayerSurface(mSurface);
setTvPlayerVolume(mVolume);
if (mCurrentProgram != null) {
// Prepare to play the upcoming program.
scheduleNextProgram();
}
}
}
private void calculateElapsedTimesFromCurrentTime() {
long currentTimeMs = getCurrentTime();
mElapsedAdsTime = 0;
mElapsedProgramTime = currentTimeMs - mCurrentProgram.getStartTimeUtcMillis();
if (mCurrentProgram.getInternalProviderData() != null) {
List<Advertisement> ads = mCurrentProgram.getInternalProviderData().getAds();
for (Advertisement ad : ads) {
if (ad.getStopTimeUtcMillis() < (currentTimeMs + PAST_AD_BUFFER_MILLIS)) {
// Subtract past ad playback time to seek to
// the correct content playback position.
long adDuration = ad.getStopTimeUtcMillis() - ad.getStartTimeUtcMillis();
mElapsedAdsTime += adDuration;
mElapsedProgramTime -= adDuration;
}
}
} else {
Log.w(TAG, "Failed to get program provider data for " +
mCurrentProgram.getTitle() + ". Try to do an EPG sync.");
}
}
private boolean playCurrentProgram() {
if (mCurrentProgram == null) {
Log.w(TAG, "Failed to get program info for " + mChannelUri + ". Try to do an " +
"EPG sync.");
return onPlayProgram(null, 0);
}
calculateElapsedTimesFromCurrentTime();
if (!scheduleNextAd()) {
return false;
}
return onPlayProgram(mCurrentProgram, mElapsedProgramTime);
}
private boolean scheduleNextAd() {
mHandler.removeMessages(MSG_PLAY_AD);
if (mPlayingRecordedProgram) {
return false;
}
long currentTimeMs = getCurrentTime();
if (mCurrentProgram.getInternalProviderData() != null) {
List<Advertisement> ads = mCurrentProgram.getInternalProviderData().getAds();
Advertisement adToPlay = null;
long timeTilAdToPlay = 0;
for (Advertisement ad : ads) {
if (ad.getStopTimeUtcMillis() > currentTimeMs + PAST_AD_BUFFER_MILLIS) {
long timeTilAd = ad.getStartTimeUtcMillis() - currentTimeMs;
if (timeTilAd < 0) {
// If tuning to the middle of a scheduled ad, the played portion
// of the ad will be skipped by the AdControllerCallback.
mHandler.sendMessage(mHandler.obtainMessage(MSG_PLAY_AD, ad));
return false;
} else if (adToPlay == null || timeTilAd < timeTilAdToPlay) {
adToPlay = ad;
timeTilAdToPlay = timeTilAd;
}
}
}
if (adToPlay != null) {
Message pauseContentPlayAdMsg = mHandler.obtainMessage(MSG_PLAY_AD, adToPlay);
mHandler.sendMessageDelayed(pauseContentPlayAdMsg, timeTilAdToPlay);
}
} else {
Log.w(TAG, "Failed to get program provider data for " +
mCurrentProgram.getTitle() + ". Try to do an EPG sync.");
}
return true;
}
private void playCurrentChannel() {
Message playAd = null;
if (mCurrentChannel.getInternalProviderData() != null) {
// Get the last played ad time for this channel.
long mostRecentOnTuneAdWatchedTime =
mContext.getSharedPreferences(PREFERENCES_FILE_KEY,
Context.MODE_PRIVATE)
.getLong(SHARED_PREFERENCES_KEY_LAST_CHANNEL_AD_PLAY +
mCurrentChannel.getId(), 0);
List<Advertisement> ads = mCurrentChannel.getInternalProviderData().getAds();
if (!ads.isEmpty() && System.currentTimeMillis() - mostRecentOnTuneAdWatchedTime
> mMinimumOnTuneAdInterval) {
// There is at most one advertisement in the channel.
playAd = mHandler.obtainMessage(MSG_PLAY_AD, ads.get(0));
}
}
onPlayChannel(mCurrentChannel);
if (playAd != null) {
playAd.sendToTarget();
} else {
mNeedToCheckChannelAd = false;
playCurrentContent();
}
}
private boolean insertAd(Advertisement ad) {
if (DEBUG) {
Log.d(TAG, "Insert an ad");
}
// If timeshifting, do not play the ad.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
long timeShiftedDifference = System.currentTimeMillis() -
mTimeShiftedPlaybackPosition;
if (mTimeShiftedPlaybackPosition != TvInputManager.TIME_SHIFT_INVALID_TIME &&
timeShiftedDifference > TIME_SHIFTED_MINIMUM_DIFFERENCE_MILLIS) {
mElapsedAdsTime += ad.getStopTimeUtcMillis() - ad.getStartTimeUtcMillis();
mTimeShiftedPlaybackPosition = mElapsedProgramTime + mElapsedAdsTime +
mCurrentProgram.getStartTimeUtcMillis();
scheduleNextAd();
scheduleNextProgram();
// If timeshifting, but skipping the ad would actually put us ahead of
// live streaming, then readjust to the live stream position.
if (mTimeShiftedPlaybackPosition > System.currentTimeMillis()) {
mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
playCurrentContent();
}
return false;
}
}
releaseAdController();
mAdController = new AdController(mContext);
mAdController.requestAds(ad.getRequestUrl(), new AdControllerCallbackImpl(ad));
return true;
}
private void releaseAdController() {
if (mAdController != null) {
mAdController.release();
mAdController = null;
}
}
/**
* Return the current {@link TvPlayer}.
*/
public abstract TvPlayer getTvPlayer();
/**
* This method is called when a particular program is to begin playing at the particular
* position. If there is not a program scheduled in the EPG, the parameter will be
* {@code null}. Developers should check the null condition and handle that case, possibly
* by manually resyncing the EPG.
*
* @param program The program that is set to be playing for a the currently tuned channel.
* @param startPosMs Start position of content video.
* @return Whether playing this program was successful.
*/
public abstract boolean onPlayProgram(Program program, long startPosMs);
/**
* This method is called when a particular recorded program is to begin playing. If the
* program does not exist, the parameter will be {@code null}.
*
* @param recordedProgram The program that is set to be playing for a the currently tuned
* channel.
* @return Whether playing this program was successful
*/
public abstract boolean onPlayRecordedProgram(RecordedProgram recordedProgram);
/**
* This method is called when the user tunes to a given channel. Developers can override
* this if they want specific behavior to occur after the user tunes but before the program
* or channel ad begins playing.
*
* @param channel The channel that the user wants to watch.
*/
public void onPlayChannel(Channel channel) {
// Do nothing.
}
/**
* Called when ads player is about to be created. Developers should override this if they
* want to enable ads insertion. Time shifting within ads is currently not supported.
*
* @param advertisement The advertisement that should be played.
*/
public void onPlayAdvertisement(Advertisement advertisement) {
throw new UnsupportedOperationException(
"Override BaseTvInputService.Session.onPlayAdvertisement(int, Uri) to enable " +
"ads insertion.");
}
/**
* Set minimum interval between two ads shown on tuning to new channels. If another
* channel ad played within the past minimum interval, tuning to a new channel will not
* trigger the new channel's ads to be shown. This provides a better user experience.
* The default value of the minimum interval is 5 minutes.
*
* @param minimumOnTuneAdInterval The minimum interval between playing channel ads
*/
public void setMinimumOnTuneAdInterval(long minimumOnTuneAdInterval) {
mMinimumOnTuneAdInterval = minimumOnTuneAdInterval;
}
public Uri getCurrentChannelUri() {
return mChannelUri;
}
private boolean blockContentIfNeeded() {
if (mCurrentContentRatingSet == null || !mTvInputManager.isParentalControlsEnabled()) {
// Content rating is invalid so we don't need to block anymore.
// Unblock content here explicitly to resume playback.
unblockContent(null);
return true;
}
// Check each content rating that the program has.
TvContentRating blockedRating = null;
for (TvContentRating contentRating : mCurrentContentRatingSet) {
if (mTvInputManager.isRatingBlocked(contentRating)
&& !mUnblockedRatingSet.contains(contentRating)) {
// This should be blocked.
blockedRating = contentRating;
}
}
if (blockedRating == null) {
// Content rating is null so we don't need to block anymore.
// Unblock content here explicitly to resume playback.
unblockContent(null);
return true;
}
mLastBlockedRating = blockedRating;
// Children restricted content might be blocked by TV app as well,
// but TIS should do its best not to show any single frame of blocked content.
onBlockContent(blockedRating);
notifyContentBlocked(blockedRating);
if (mTimeShiftedPlaybackPosition != TvInputManager.TIME_SHIFT_INVALID_TIME) {
onTimeShiftPause();
}
return false;
}
private void unblockContent(TvContentRating rating) {
// TIS should unblock content only if unblock request is legitimate.
if (rating == null || mLastBlockedRating == null || rating.equals(mLastBlockedRating)) {
mLastBlockedRating = null;
if (rating != null) {
mUnblockedRatingSet.add(rating);
}
notifyContentAllowed();
}
}
private final class AdControllerCallbackImpl implements AdController.AdControllerCallback {
private Advertisement mAdvertisement;
public AdControllerCallbackImpl(Advertisement advertisement) {
mAdvertisement = advertisement;
}
@Override
public TvPlayer onAdReadyToPlay(String adVideoUrl) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
}
onPlayAdvertisement(new Advertisement.Builder(mAdvertisement)
.setRequestUrl(adVideoUrl)
.build());
setTvPlayerSurface(mSurface);
setTvPlayerVolume(mVolume);
long currentTimeMs = System.currentTimeMillis();
long adStartTime = mAdvertisement.getStartTimeUtcMillis();
if (adStartTime > 0 && adStartTime < currentTimeMs) {
getTvPlayer().seekTo(currentTimeMs - adStartTime);
}
return getTvPlayer();
}
@Override
public void onAdCompleted() {
if (DEBUG) {
Log.i(TAG, "Ad completed");
}
// Check if the ad played was an on-tune Channel ad
if (mNeedToCheckChannelAd) {
// In some TV apps, opening the guide will cause the session to restart, so this
// value is stored in SharedPreferences to persist between sessions.
SharedPreferences.Editor editor = mContext.getSharedPreferences(
PREFERENCES_FILE_KEY, Context.MODE_PRIVATE).edit();
editor.putLong(SHARED_PREFERENCES_KEY_LAST_CHANNEL_AD_PLAY +
mCurrentChannel.getId(), System.currentTimeMillis());
editor.apply();
mNeedToCheckChannelAd = false;
}
playCurrentContent();
}
@Override
public void onAdError() {
Log.e(TAG, "An error occurred playing ads");
notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
onAdCompleted();
}
}
private class GetCurrentProgramRunnable implements Runnable {
private final Uri mChannelUri;
GetCurrentProgramRunnable(Uri channelUri) {
mChannelUri = channelUri;
}
@Override
public void run() {
ContentResolver resolver = mContext.getContentResolver();
Program program = null;
long timeShiftedDifference = System.currentTimeMillis() -
mTimeShiftedPlaybackPosition;
if (mTimeShiftedPlaybackPosition != TvInputManager.TIME_SHIFT_INVALID_TIME &&
timeShiftedDifference > TIME_SHIFTED_MINIMUM_DIFFERENCE_MILLIS) {
program = TvContractUtils.getNextProgram(resolver, mChannelUri,
mCurrentProgram);
} else {
mTimeShiftedPlaybackPosition = TvInputManager.TIME_SHIFT_INVALID_TIME;
program = TvContractUtils.getCurrentProgram(resolver, mChannelUri);
}
mHandler.removeMessages(MSG_PLAY_CONTENT);
mHandler.obtainMessage(MSG_PLAY_CONTENT, program).sendToTarget();
}
}
private class GetRecordedProgramRunnable implements Runnable {
private final Uri mRecordedProgramUri;
GetRecordedProgramRunnable(Uri recordedProgramUri) {
mRecordedProgramUri = recordedProgramUri;
}
@Override
public void run() {
ContentResolver contentResolver = mContext.getContentResolver();
Cursor cursor = contentResolver.query(mRecordedProgramUri,
RecordedProgram.PROJECTION, null, null, null);
if (cursor == null) {
// The recorded program does not exist.
notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
} else {
if (cursor.moveToNext()) {
RecordedProgram recordedProgram = RecordedProgram.fromCursor(cursor);
if (DEBUG) {
Log.d(TAG, "Play program " + recordedProgram.getTitle());
Log.d(TAG, recordedProgram.getRecordingDataUri());
}
if (recordedProgram == null) {
Log.e(TAG, "RecordedProgram at " + mRecordedProgramUri + " does not " +
"exist");
notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
}
mHandler.removeMessages(MSG_PLAY_RECORDED_CONTENT);
mHandler.obtainMessage(MSG_PLAY_RECORDED_CONTENT, recordedProgram)
.sendToTarget();
}
}
}
}
}
/**
* A {@link BaseTvInputService.RecordingSession} is created when a user wants to begin recording
* a particular channel or program.
*/
@RequiresApi(api = Build.VERSION_CODES.N)
public static abstract class RecordingSession extends TvInputService.RecordingSession {
private Context mContext;
private String mInputId;
private Uri mChannelUri;
private Uri mProgramUri;
private Handler mDbHandler;
public RecordingSession(Context context, String inputId) {
super(context);
mContext = context;
mInputId = inputId;
mDbHandler = new Handler(mDbHandlerThread.getLooper());
}
@Override
public void onTune(Uri uri) {
mChannelUri = uri;
}
@Override
public void onStartRecording(final Uri uri) {
mProgramUri = uri;
}
@Override
public void onStopRecording() {
// Run in the database thread
mDbHandler.post(new Runnable() {
@Override
public void run() {
// Check if user wanted to record a specific program.
if (mProgramUri != null) {
Cursor programCursor =
mContext.getContentResolver().query(mProgramUri, Program.PROJECTION,
null, null, null);
if (programCursor != null && programCursor.moveToNext()) {
Program programToRecord = Program.fromCursor(programCursor);
onStopRecording(programToRecord);
} else {
Channel recordedChannel =
TvContractUtils.getChannel(mContext.getContentResolver(),
mChannelUri);
onStopRecordingChannel(recordedChannel);
}
} else {
// User is recording a channel
Channel recordedChannel =
TvContractUtils.getChannel(mContext.getContentResolver(),
mChannelUri);
onStopRecordingChannel(recordedChannel);
}
}
});
}
/**
* Called when the application requests to stop TV program recording. Recording must stop
* immediately when this method is called.
* </p>
* The session must create a new data entry using
* {@link #notifyRecordingStopped(RecordedProgram)} that describes the new
* {@link RecordedProgram} and call {@link #notifyRecordingStopped(Uri)} with the URI to
* that entry. If the stop request cannot be fulfilled, the session must call
* {@link #notifyError(int)}.
*
* @param programToRecord The program set by the user to be recorded.
*/
public abstract void onStopRecording(Program programToRecord);
/**
* Called when the application requests to stop TV channel recording. Recording must stop
* immediately when this method is called.
* </p>
* The session must create a new data entry using
* {@link #notifyRecordingStopped(RecordedProgram)} that describes the new
* {@link RecordedProgram} and call {@link #notifyRecordingStopped(Uri)} with the URI to
* that entry. If the stop request cannot be fulfilled, the session must call
* {@link #notifyError(int)}.
*
* @param channelToRecord The channel set by the user to be recorded.
*/
public abstract void onStopRecordingChannel(Channel channelToRecord);
/**
* Notify the TV app that the recording has ended.
*
* @param recordedProgram The program that was recorded and should be saved.
*/
public void notifyRecordingStopped(final RecordedProgram recordedProgram) {
mDbHandler.post(new Runnable() {
@Override
public void run() {
Uri recordedProgramUri = mContext.getContentResolver().insert(
TvContract.RecordedPrograms.CONTENT_URI,
recordedProgram.toContentValues());
notifyRecordingStopped(recordedProgramUri);
}
});
}
}
}