1 /*
2  * Copyright 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.example.android.sampletvinput.rich;
18 
19 import android.content.BroadcastReceiver;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.IntentFilter;
23 import android.graphics.Point;
24 import android.media.tv.TvContentRating;
25 import android.media.tv.TvInputManager;
26 import android.media.tv.TvInputService;
27 import android.media.tv.TvTrackInfo;
28 import android.net.Uri;
29 import android.os.Handler;
30 import android.os.HandlerThread;
31 import android.os.Message;
32 import android.text.TextUtils;
33 import android.util.Log;
34 import android.view.Display;
35 import android.view.LayoutInflater;
36 import android.view.Surface;
37 import android.view.View;
38 import android.view.WindowManager;
39 import android.view.accessibility.CaptioningManager;
40 
41 import com.example.android.sampletvinput.R;
42 import com.example.android.sampletvinput.TvContractUtils;
43 import com.example.android.sampletvinput.player.TvInputPlayer;
44 import com.example.android.sampletvinput.syncadapter.SyncUtils;
45 import com.google.android.exoplayer.ExoPlaybackException;
46 import com.google.android.exoplayer.ExoPlayer;
47 import com.google.android.exoplayer.text.CaptionStyleCompat;
48 import com.google.android.exoplayer.text.SubtitleView;
49 
50 import java.util.ArrayList;
51 import java.util.Collections;
52 import java.util.HashSet;
53 import java.util.List;
54 import java.util.Set;
55 
56 /**
57  * TvInputService which provides a full implementation of EPG, subtitles, multi-audio,
58  * parental controls, and overlay view.
59  */
60 public class RichTvInputService extends TvInputService {
61     private static final String TAG = "RichTvInputService";
62 
63     private HandlerThread mHandlerThread;
64     private Handler mDbHandler;
65 
66     private List<RichTvInputSessionImpl> mSessions;
67     private CaptioningManager mCaptioningManager;
68 
69     private final BroadcastReceiver mParentalControlsBroadcastReceiver = new BroadcastReceiver() {
70         @Override
71         public void onReceive(Context context, Intent intent) {
72             if (mSessions != null) {
73                 for (RichTvInputSessionImpl session : mSessions) {
74                     session.checkContentBlockNeeded();
75                 }
76             }
77         }
78     };
79 
80     @Override
onCreate()81     public void onCreate() {
82         super.onCreate();
83         mHandlerThread = new HandlerThread(getClass().getSimpleName());
84         mHandlerThread.start();
85         mDbHandler = new Handler(mHandlerThread.getLooper());
86         mCaptioningManager = (CaptioningManager) getSystemService(Context.CAPTIONING_SERVICE);
87 
88         setTheme(android.R.style.Theme_Holo_Light_NoActionBar);
89 
90         mSessions = new ArrayList<RichTvInputSessionImpl>();
91         IntentFilter intentFilter = new IntentFilter();
92         intentFilter.addAction(TvInputManager.ACTION_BLOCKED_RATINGS_CHANGED);
93         intentFilter.addAction(TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED);
94         registerReceiver(mParentalControlsBroadcastReceiver, intentFilter);
95     }
96 
97     @Override
onDestroy()98     public void onDestroy() {
99         super.onDestroy();
100         unregisterReceiver(mParentalControlsBroadcastReceiver);
101         mHandlerThread.quit();
102         mHandlerThread = null;
103         mDbHandler = null;
104     }
105 
106     @Override
onCreateSession(String inputId)107     public final Session onCreateSession(String inputId) {
108         RichTvInputSessionImpl session = new RichTvInputSessionImpl(this, inputId);
109         session.setOverlayViewEnabled(true);
110         mSessions.add(session);
111         return session;
112     }
113 
114     class RichTvInputSessionImpl extends TvInputService.Session implements Handler.Callback {
115         private static final int MSG_PLAY_PROGRAM = 1000;
116         private static final float CAPTION_LINE_HEIGHT_RATIO = 0.0533f;
117 
118         private final Context mContext;
119         private final String mInputId;
120         private TvInputManager mTvInputManager;
121         protected TvInputPlayer mPlayer;
122         private Surface mSurface;
123         private float mVolume;
124         private boolean mCaptionEnabled;
125         private PlaybackInfo mCurrentPlaybackInfo;
126         private TvContentRating mLastBlockedRating;
127         private TvContentRating mCurrentContentRating;
128         private String mSelectedSubtitleTrackId;
129         private SubtitleView mSubtitleView;
130         private boolean mEpgSyncRequested;
131         private final Set<TvContentRating> mUnblockedRatingSet = new HashSet<>();
132         private Handler mHandler;
133 
134         private final TvInputPlayer.Callback mPlayerCallback = new TvInputPlayer.Callback() {
135             private boolean mFirstFrameDrawn;
136             @Override
137             public void onPrepared() {
138                 mFirstFrameDrawn = false;
139                 List<TvTrackInfo> tracks = new ArrayList<>();
140                 Collections.addAll(tracks, mPlayer.getTracks(TvTrackInfo.TYPE_AUDIO));
141                 Collections.addAll(tracks, mPlayer.getTracks(TvTrackInfo.TYPE_VIDEO));
142                 Collections.addAll(tracks, mPlayer.getTracks(TvTrackInfo.TYPE_SUBTITLE));
143 
144                 notifyTracksChanged(tracks);
145                 notifyTrackSelected(TvTrackInfo.TYPE_AUDIO, mPlayer.getSelectedTrack(
146                         TvTrackInfo.TYPE_AUDIO));
147                 notifyTrackSelected(TvTrackInfo.TYPE_VIDEO, mPlayer.getSelectedTrack(
148                         TvTrackInfo.TYPE_VIDEO));
149                 notifyTrackSelected(TvTrackInfo.TYPE_SUBTITLE, mPlayer.getSelectedTrack(
150                         TvTrackInfo.TYPE_SUBTITLE));
151             }
152 
153             @Override
154             public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
155                 if (playWhenReady == true && playbackState == ExoPlayer.STATE_BUFFERING) {
156                     if (mFirstFrameDrawn) {
157                         notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING);
158                     }
159                 } else if (playWhenReady == true && playbackState == ExoPlayer.STATE_READY) {
160                     notifyVideoAvailable();
161                 }
162             }
163 
164             @Override
165             public void onPlayWhenReadyCommitted() {
166                 // Do nothing.
167             }
168 
169             @Override
170             public void onPlayerError(ExoPlaybackException e) {
171                 // Do nothing.
172             }
173 
174             @Override
175             public void onDrawnToSurface(Surface surface) {
176                 mFirstFrameDrawn = true;
177                 notifyVideoAvailable();
178             }
179 
180             @Override
181             public void onText(String text) {
182                 if (mSubtitleView != null) {
183                     if (TextUtils.isEmpty(text)) {
184                         mSubtitleView.setVisibility(View.INVISIBLE);
185                     } else {
186                         mSubtitleView.setVisibility(View.VISIBLE);
187                         mSubtitleView.setText(text);
188                     }
189                 }
190             }
191         };
192 
193         private PlayCurrentProgramRunnable mPlayCurrentProgramRunnable;
194 
RichTvInputSessionImpl(Context context, String inputId)195         protected RichTvInputSessionImpl(Context context, String inputId) {
196             super(context);
197 
198             mContext = context;
199             mInputId = inputId;
200             mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
201             mLastBlockedRating = null;
202             mCaptionEnabled = mCaptioningManager.isEnabled();
203             mHandler = new Handler(this);
204         }
205 
206         @Override
handleMessage(Message msg)207         public boolean handleMessage(Message msg) {
208             if (msg.what == MSG_PLAY_PROGRAM) {
209                 playProgram((PlaybackInfo) msg.obj);
210                 return true;
211             }
212             return false;
213         }
214 
215         @Override
onRelease()216         public void onRelease() {
217             if (mDbHandler != null) {
218                 mDbHandler.removeCallbacks(mPlayCurrentProgramRunnable);
219             }
220             releasePlayer();
221             mSessions.remove(this);
222         }
223 
224         @Override
onCreateOverlayView()225         public View onCreateOverlayView() {
226             LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
227             View view = inflater.inflate(R.layout.overlayview, null);
228             mSubtitleView = (SubtitleView) view.findViewById(R.id.subtitles);
229 
230             // Configure the subtitle view.
231             CaptionStyleCompat captionStyle;
232             float captionTextSize = getCaptionFontSize();
233             captionStyle = CaptionStyleCompat.createFromCaptionStyle(
234                     mCaptioningManager.getUserStyle());
235             captionTextSize *= mCaptioningManager.getFontScale();
236             mSubtitleView.setStyle(captionStyle);
237             mSubtitleView.setTextSize(captionTextSize);
238             return view;
239         }
240 
241         @Override
onSetSurface(Surface surface)242         public boolean onSetSurface(Surface surface) {
243             if (mPlayer != null) {
244                 mPlayer.setSurface(surface);
245             }
246             mSurface = surface;
247             return true;
248         }
249 
250         @Override
onSetStreamVolume(float volume)251         public void onSetStreamVolume(float volume) {
252             if (mPlayer != null) {
253                 mPlayer.setVolume(volume);
254             }
255             mVolume = volume;
256         }
257 
playProgram(PlaybackInfo info)258         private boolean playProgram(PlaybackInfo info) {
259             releasePlayer();
260 
261             mCurrentPlaybackInfo = info;
262             mCurrentContentRating = info.contentRatings.length > 0 ?
263                     info.contentRatings[0] : null;
264             mPlayer = new TvInputPlayer();
265             mPlayer.addCallback(mPlayerCallback);
266             mPlayer.prepare(RichTvInputService.this, Uri.parse(info.videoUrl), info.videoType);
267             mPlayer.setSurface(mSurface);
268             mPlayer.setVolume(mVolume);
269 
270             long nowMs = System.currentTimeMillis();
271             if (info.videoType != TvInputPlayer.SOURCE_TYPE_HTTP_PROGRESSIVE) {
272                 // If source type is HTTTP progressive, just play from the beginning.
273                 // TODO: Seeking on http progressive source takes too long.
274                 //       Enhance ExoPlayer/MediaExtractor and remove the condition above.
275                 int seekPosMs = (int) (nowMs - info.startTimeMs);
276                 if (seekPosMs > 0) {
277                     mPlayer.seekTo(seekPosMs);
278                 }
279             }
280             mPlayer.setPlayWhenReady(true);
281 
282             checkContentBlockNeeded();
283             mDbHandler.postDelayed(mPlayCurrentProgramRunnable, info.endTimeMs - nowMs + 1000);
284             return true;
285         }
286 
287         @Override
onTune(Uri channelUri)288         public boolean onTune(Uri channelUri) {
289             if (mSubtitleView != null) {
290                 mSubtitleView.setVisibility(View.INVISIBLE);
291             }
292             notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
293             mUnblockedRatingSet.clear();
294 
295             mDbHandler.removeCallbacks(mPlayCurrentProgramRunnable);
296             mPlayCurrentProgramRunnable = new PlayCurrentProgramRunnable(channelUri);
297             mDbHandler.post(mPlayCurrentProgramRunnable);
298             return true;
299         }
300 
301         @Override
onSetCaptionEnabled(boolean enabled)302         public void onSetCaptionEnabled(boolean enabled) {
303             mCaptionEnabled = enabled;
304             if (mPlayer != null) {
305                 if (enabled) {
306                     if (mSelectedSubtitleTrackId != null && mPlayer != null) {
307                         mPlayer.selectTrack(TvTrackInfo.TYPE_SUBTITLE, mSelectedSubtitleTrackId);
308                     }
309                 } else {
310                     mPlayer.selectTrack(TvTrackInfo.TYPE_SUBTITLE, null);
311                 }
312             }
313         }
314 
315         @Override
onSelectTrack(int type, String trackId)316         public boolean onSelectTrack(int type, String trackId) {
317             if (mPlayer != null) {
318                 if (type == TvTrackInfo.TYPE_SUBTITLE) {
319                     if (!mCaptionEnabled && trackId != null) {
320                         return false;
321                     }
322                     mSelectedSubtitleTrackId = trackId;
323                     if (trackId == null) {
324                         mSubtitleView.setVisibility(View.INVISIBLE);
325                     }
326                 }
327                 if (mPlayer.selectTrack(type, trackId)) {
328                     notifyTrackSelected(type, trackId);
329                     return true;
330                 }
331             }
332             return false;
333         }
334 
335         @Override
onUnblockContent(TvContentRating rating)336         public void onUnblockContent(TvContentRating rating) {
337             if (rating != null) {
338                 unblockContent(rating);
339             }
340         }
341 
releasePlayer()342         private void releasePlayer() {
343             if (mPlayer != null) {
344                 mPlayer.removeCallback(mPlayerCallback);
345                 mPlayer.setSurface(null);
346                 mPlayer.stop();
347                 mPlayer.release();
348                 mPlayer = null;
349             }
350         }
351 
checkContentBlockNeeded()352         private void checkContentBlockNeeded() {
353             if (mCurrentContentRating == null || !mTvInputManager.isParentalControlsEnabled()
354                     || !mTvInputManager.isRatingBlocked(mCurrentContentRating)
355                     || mUnblockedRatingSet.contains(mCurrentContentRating)) {
356                 // Content rating is changed so we don't need to block anymore.
357                 // Unblock content here explicitly to resume playback.
358                 unblockContent(null);
359                 return;
360             }
361 
362             mLastBlockedRating = mCurrentContentRating;
363             if (mPlayer != null) {
364                 // Children restricted content might be blocked by TV app as well,
365                 // but TIS should do its best not to show any single frame of blocked content.
366                 releasePlayer();
367             }
368 
369             notifyContentBlocked(mCurrentContentRating);
370         }
371 
unblockContent(TvContentRating rating)372         private void unblockContent(TvContentRating rating) {
373             // TIS should unblock content only if unblock request is legitimate.
374             if (rating == null || mLastBlockedRating == null
375                     || (mLastBlockedRating != null && rating.equals(mLastBlockedRating))) {
376                 mLastBlockedRating = null;
377                 if (rating != null) {
378                     mUnblockedRatingSet.add(rating);
379                 }
380                 if (mPlayer == null && mCurrentPlaybackInfo != null) {
381                     playProgram(mCurrentPlaybackInfo);
382                 }
383                 notifyContentAllowed();
384             }
385         }
386 
getCaptionFontSize()387         private float getCaptionFontSize() {
388             Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE))
389                     .getDefaultDisplay();
390             Point displaySize = new Point();
391             display.getSize(displaySize);
392             return Math.max(getResources().getDimension(R.dimen.subtitle_minimum_font_size),
393                     CAPTION_LINE_HEIGHT_RATIO * Math.min(displaySize.x, displaySize.y));
394         }
395 
396         private class PlayCurrentProgramRunnable implements Runnable {
397             private static final int RETRY_DELAY_MS = 2000;
398             private final Uri mChannelUri;
399 
PlayCurrentProgramRunnable(Uri channelUri)400             public PlayCurrentProgramRunnable(Uri channelUri) {
401                 mChannelUri = channelUri;
402             }
403 
404             @Override
run()405             public void run() {
406                 long nowMs = System.currentTimeMillis();
407                 List<PlaybackInfo> programs = TvContractUtils.getProgramPlaybackInfo(
408                         mContext.getContentResolver(), mChannelUri, nowMs, nowMs + 1, 1);
409                 if (!programs.isEmpty()) {
410                     mHandler.removeMessages(MSG_PLAY_PROGRAM);
411                     mHandler.obtainMessage(MSG_PLAY_PROGRAM, programs.get(0)).sendToTarget();
412                 } else {
413                     Log.w(TAG, "Failed to get program info for " + mChannelUri + ". Retry in " +
414                             RETRY_DELAY_MS + "ms.");
415                     mDbHandler.postDelayed(mPlayCurrentProgramRunnable, RETRY_DELAY_MS);
416                     if (!mEpgSyncRequested) {
417                         SyncUtils.requestSync(mInputId);
418                         mEpgSyncRequested = true;
419                     }
420                 }
421             }
422         }
423     }
424 
425     public static final class ChannelInfo {
426         public final String number;
427         public final String name;
428         public final String logoUrl;
429         public final int originalNetworkId;
430         public final int transportStreamId;
431         public final int serviceId;
432         public final int videoWidth;
433         public final int videoHeight;
434         public final List<ProgramInfo> programs;
435 
ChannelInfo(String number, String name, String logoUrl, int originalNetworkId, int transportStreamId, int serviceId, int videoWidth, int videoHeight, List<ProgramInfo> programs)436         public ChannelInfo(String number, String name, String logoUrl, int originalNetworkId,
437                            int transportStreamId, int serviceId, int videoWidth, int videoHeight,
438                            List<ProgramInfo> programs) {
439             this.number = number;
440             this.name = name;
441             this.logoUrl = logoUrl;
442             this.originalNetworkId = originalNetworkId;
443             this.transportStreamId = transportStreamId;
444             this.serviceId = serviceId;
445             this.videoWidth = videoWidth;
446             this.videoHeight = videoHeight;
447             this.programs = programs;
448         }
449     }
450 
451     public static final class ProgramInfo {
452         public final String title;
453         public final String posterArtUri;
454         public final String description;
455         public final long durationSec;
456         public final String videoUrl;
457         public final int videoType;
458         public final int resourceId;
459         public final TvContentRating[] contentRatings;
460 
ProgramInfo(String title, String posterArtUri, String description, long durationSec, TvContentRating[] contentRatings, String videoUrl, int videoType, int resourceId)461         public ProgramInfo(String title, String posterArtUri, String description, long durationSec,
462                            TvContentRating[] contentRatings, String videoUrl, int videoType, int resourceId) {
463             this.title = title;
464             this.posterArtUri = posterArtUri;
465             this.description = description;
466             this.durationSec = durationSec;
467             this.contentRatings = contentRatings;
468             this.videoUrl = videoUrl;
469             this.videoType = videoType;
470             this.resourceId = resourceId;
471         }
472     }
473 
474     public static final class PlaybackInfo {
475         public final long startTimeMs;
476         public final long endTimeMs;
477         public final String videoUrl;
478         public final int videoType;
479         public final TvContentRating[] contentRatings;
480 
PlaybackInfo(long startTimeMs, long endTimeMs, String videoUrl, int videoType, TvContentRating[] contentRatings)481         public PlaybackInfo(long startTimeMs, long endTimeMs, String videoUrl, int videoType,
482                             TvContentRating[] contentRatings) {
483             this.startTimeMs = startTimeMs;
484             this.endTimeMs = endTimeMs;
485             this.contentRatings = contentRatings;
486             this.videoUrl = videoUrl;
487             this.videoType = videoType;
488         }
489     }
490 
491     public static final class TvInput {
492         public final String displayName;
493         public final String name;
494         public final String description;
495         public final String logoThumbUrl;
496         public final String logoBackgroundUrl;
497 
TvInput(String displayName, String name, String description, String logoThumbUrl, String logoBackgroundUrl)498         public TvInput(String displayName,
499                        String name,
500                        String description,
501                        String logoThumbUrl,
502                        String logoBackgroundUrl) {
503             this.displayName = displayName;
504             this.name = name;
505             this.description = description;
506             this.logoThumbUrl = logoThumbUrl;
507             this.logoBackgroundUrl = logoBackgroundUrl;
508         }
509     }
510 }
511