• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 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.android.tv.guide;
18 
19 import android.annotation.SuppressLint;
20 import android.content.Context;
21 import android.content.res.ColorStateList;
22 import android.content.res.Resources;
23 import android.graphics.drawable.Drawable;
24 import android.graphics.drawable.LayerDrawable;
25 import android.graphics.drawable.StateListDrawable;
26 import android.os.Handler;
27 import android.text.SpannableStringBuilder;
28 import android.text.Spanned;
29 import android.text.TextUtils;
30 import android.text.style.TextAppearanceSpan;
31 import android.util.AttributeSet;
32 import android.util.Log;
33 import android.view.View;
34 import android.view.ViewGroup;
35 import android.widget.TextView;
36 import android.widget.Toast;
37 import com.android.tv.MainActivity;
38 import com.android.tv.R;
39 import com.android.tv.TvSingletons;
40 import com.android.tv.analytics.Tracker;
41 import com.android.tv.common.feature.CommonFeatures;
42 import com.android.tv.common.util.Clock;
43 import com.android.tv.data.ChannelDataManager;
44 import com.android.tv.data.Program;
45 import com.android.tv.data.api.Channel;
46 import com.android.tv.dvr.DvrManager;
47 import com.android.tv.dvr.data.ScheduledRecording;
48 import com.android.tv.dvr.ui.DvrUiHelper;
49 import com.android.tv.guide.ProgramManager.TableEntry;
50 import com.android.tv.util.ToastUtils;
51 import com.android.tv.util.Utils;
52 import java.lang.reflect.InvocationTargetException;
53 import java.util.concurrent.TimeUnit;
54 
55 public class ProgramItemView extends TextView {
56     private static final String TAG = "ProgramItemView";
57 
58     private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1);
59     private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE
60 
61     // State indicating the focused program is the current program
62     private static final int[] STATE_CURRENT_PROGRAM = {R.attr.state_current_program};
63 
64     // Workaround state in order to not use too much texture memory for RippleDrawable
65     private static final int[] STATE_TOO_WIDE = {R.attr.state_program_too_wide};
66 
67     private static int sVisibleThreshold;
68     private static int sItemPadding;
69     private static int sCompoundDrawablePadding;
70     private static TextAppearanceSpan sProgramTitleStyle;
71     private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
72     private static TextAppearanceSpan sEpisodeTitleStyle;
73     private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
74 
75     private final DvrManager mDvrManager;
76     private final Clock mClock;
77     private final ChannelDataManager mChannelDataManager;
78     private ProgramGuide mProgramGuide;
79     private TableEntry mTableEntry;
80     private int mMaxWidthForRipple;
81     private int mTextWidth;
82 
83     // If set this flag disables requests to re-layout the parent view as a result of changing
84     // this view, improving performance. This also prevents the parent view to lose child focus
85     // as a result of the re-layout (see b/21378855).
86     private boolean mPreventParentRelayout;
87 
88     private static final View.OnClickListener ON_CLICKED =
89             new View.OnClickListener() {
90                 @Override
91                 public void onClick(final View view) {
92                     TableEntry entry = ((ProgramItemView) view).mTableEntry;
93                     Clock clock = ((ProgramItemView) view).mClock;
94                     if (entry == null) {
95                         // do nothing
96                         return;
97                     }
98                     TvSingletons singletons = TvSingletons.getSingletons(view.getContext());
99                     Tracker tracker = singletons.getTracker();
100                     tracker.sendEpgItemClicked();
101                     final MainActivity tvActivity = (MainActivity) view.getContext();
102                     final Channel channel =
103                             tvActivity.getChannelDataManager().getChannel(entry.channelId);
104                     if (entry.isCurrentProgram()) {
105                         view.postDelayed(
106                                 new Runnable() {
107                                     @Override
108                                     public void run() {
109                                         tvActivity.tuneToChannel(channel);
110                                         tvActivity.hideOverlaysForTune();
111                                     }
112                                 },
113                                 entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple
114                                         ? 0
115                                         : view.getResources()
116                                                 .getInteger(
117                                                         R.integer
118                                                                 .program_guide_ripple_anim_duration));
119                     } else if (entry.program != null
120                             && CommonFeatures.DVR.isEnabled(view.getContext())) {
121                         DvrManager dvrManager = singletons.getDvrManager();
122                         if (entry.entryStartUtcMillis > clock.currentTimeMillis()
123                                 && dvrManager.isProgramRecordable(entry.program)) {
124                             if (entry.scheduledRecording == null) {
125                                 DvrUiHelper.checkStorageStatusAndShowErrorMessage(
126                                         tvActivity,
127                                         channel.getInputId(),
128                                         new Runnable() {
129                                             @Override
130                                             public void run() {
131                                                 DvrUiHelper.requestRecordingFutureProgram(
132                                                         tvActivity, entry.program, false);
133                                             }
134                                         });
135                             } else {
136                                 dvrManager.removeScheduledRecording(entry.scheduledRecording);
137                                 String msg =
138                                         view.getResources()
139                                                 .getString(
140                                                         R.string.dvr_schedules_deletion_info,
141                                                         entry.program.getTitle());
142                                 ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
143                             }
144                         } else {
145                             ToastUtils.show(
146                                     view.getContext(),
147                                     view.getResources()
148                                             .getString(R.string.dvr_msg_cannot_record_program),
149                                     Toast.LENGTH_SHORT);
150                         }
151                     }
152                 }
153             };
154 
155     private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
156             new View.OnFocusChangeListener() {
157                 @Override
158                 public void onFocusChange(View view, boolean hasFocus) {
159                     if (hasFocus) {
160                         ((ProgramItemView) view).mUpdateFocus.run();
161                     } else {
162                         Handler handler = view.getHandler();
163                         if (handler != null) {
164                             handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus);
165                         }
166                     }
167                 }
168             };
169 
170     private final Runnable mUpdateFocus =
171             new Runnable() {
172                 @Override
173                 public void run() {
174                     refreshDrawableState();
175                     TableEntry entry = mTableEntry;
176                     if (entry == null) {
177                         // do nothing
178                         return;
179                     }
180                     if (entry.isCurrentProgram()) {
181                         Drawable background = getBackground();
182                         if (!mProgramGuide.isActive() || mProgramGuide.isRunningAnimation()) {
183                             // If program guide is not active or is during showing/hiding,
184                             // the animation is unnecessary, skip it.
185                             background.jumpToCurrentState();
186                         }
187                         int progress =
188                                 getProgress(
189                                         mClock, entry.entryStartUtcMillis, entry.entryEndUtcMillis);
190                         setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress);
191                     }
192                     if (getHandler() != null) {
193                         getHandler()
194                                 .postAtTime(
195                                         this,
196                                         Utils.ceilTime(
197                                                 mClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY));
198                     }
199                 }
200             };
201 
ProgramItemView(Context context)202     public ProgramItemView(Context context) {
203         this(context, null);
204     }
205 
ProgramItemView(Context context, AttributeSet attrs)206     public ProgramItemView(Context context, AttributeSet attrs) {
207         this(context, attrs, 0);
208     }
209 
ProgramItemView(Context context, AttributeSet attrs, int defStyle)210     public ProgramItemView(Context context, AttributeSet attrs, int defStyle) {
211         super(context, attrs, defStyle);
212         setOnClickListener(ON_CLICKED);
213         setOnFocusChangeListener(ON_FOCUS_CHANGED);
214         TvSingletons singletons = TvSingletons.getSingletons(getContext());
215         mDvrManager = singletons.getDvrManager();
216         mChannelDataManager = singletons.getChannelDataManager();
217         mClock = singletons.getClock();
218     }
219 
initIfNeeded()220     private void initIfNeeded() {
221         if (sVisibleThreshold != 0) {
222             return;
223         }
224         Resources res = getContext().getResources();
225 
226         sVisibleThreshold =
227                 res.getDimensionPixelOffset(R.dimen.program_guide_table_item_visible_threshold);
228 
229         sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
230         sCompoundDrawablePadding =
231                 res.getDimensionPixelOffset(
232                         R.dimen.program_guide_table_item_compound_drawable_padding);
233 
234         ColorStateList programTitleColor =
235                 ColorStateList.valueOf(
236                         res.getColor(
237                                 R.color.program_guide_table_item_program_title_text_color, null));
238         ColorStateList grayedOutProgramTitleColor =
239                 res.getColorStateList(
240                         R.color.program_guide_table_item_grayed_out_program_text_color, null);
241         ColorStateList episodeTitleColor =
242                 ColorStateList.valueOf(
243                         res.getColor(
244                                 R.color.program_guide_table_item_program_episode_title_text_color,
245                                 null));
246         ColorStateList grayedOutEpisodeTitleColor =
247                 ColorStateList.valueOf(
248                         res.getColor(
249                                 R.color
250                                         .program_guide_table_item_grayed_out_program_episode_title_text_color,
251                                 null));
252         int programTitleSize =
253                 res.getDimensionPixelSize(R.dimen.program_guide_table_item_program_title_font_size);
254         int episodeTitleSize =
255                 res.getDimensionPixelSize(
256                         R.dimen.program_guide_table_item_program_episode_title_font_size);
257 
258         sProgramTitleStyle =
259                 new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, null);
260         sGrayedOutProgramTitleStyle =
261                 new TextAppearanceSpan(null, 0, programTitleSize, grayedOutProgramTitleColor, null);
262         sEpisodeTitleStyle =
263                 new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, null);
264         sGrayedOutEpisodeTitleStyle =
265                 new TextAppearanceSpan(null, 0, episodeTitleSize, grayedOutEpisodeTitleColor, null);
266     }
267 
268     @Override
onFinishInflate()269     protected void onFinishInflate() {
270         super.onFinishInflate();
271         initIfNeeded();
272     }
273 
274     @Override
onCreateDrawableState(int extraSpace)275     protected int[] onCreateDrawableState(int extraSpace) {
276         if (mTableEntry != null) {
277             int[] states =
278                     super.onCreateDrawableState(
279                             extraSpace + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length);
280             if (mTableEntry.isCurrentProgram()) {
281                 mergeDrawableStates(states, STATE_CURRENT_PROGRAM);
282             }
283             if (mTableEntry.getWidth() > mMaxWidthForRipple) {
284                 mergeDrawableStates(states, STATE_TOO_WIDE);
285             }
286             return states;
287         }
288         return super.onCreateDrawableState(extraSpace);
289     }
290 
getTableEntry()291     public TableEntry getTableEntry() {
292         return mTableEntry;
293     }
294 
295     @SuppressLint("SwitchIntDef")
setValues( ProgramGuide programGuide, TableEntry entry, int selectedGenreId, long fromUtcMillis, long toUtcMillis, String gapTitle)296     public void setValues(
297             ProgramGuide programGuide,
298             TableEntry entry,
299             int selectedGenreId,
300             long fromUtcMillis,
301             long toUtcMillis,
302             String gapTitle) {
303         mProgramGuide = programGuide;
304         mTableEntry = entry;
305 
306         ViewGroup.LayoutParams layoutParams = getLayoutParams();
307         if (layoutParams != null) {
308             // There is no layoutParams in the tests so we skip this
309             layoutParams.width = entry.getWidth();
310             setLayoutParams(layoutParams);
311         }
312         String title = mTableEntry.program != null ? mTableEntry.program.getTitle() : null;
313         if (mTableEntry.isGap()) {
314             title = gapTitle;
315         }
316         if (TextUtils.isEmpty(title)) {
317             title = getResources().getString(R.string.program_title_for_no_information);
318         }
319         updateText(selectedGenreId, title);
320         updateIcons();
321         updateContentDescription(title);
322         measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
323         mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
324         // Maximum width for us to use a ripple
325         mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis);
326     }
327 
isEntryWideEnough()328     private boolean isEntryWideEnough() {
329         return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold;
330     }
331 
updateText(int selectedGenreId, String title)332     private void updateText(int selectedGenreId, String title) {
333         if (!isEntryWideEnough()) {
334             setText(null);
335             return;
336         }
337 
338         String episode =
339                 mTableEntry.program != null
340                         ? mTableEntry.program.getEpisodeDisplayTitle(getContext())
341                         : null;
342 
343         TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle;
344         TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle;
345         if (mTableEntry.isGap()) {
346 
347             episode = null;
348         } else if (mTableEntry.hasGenre(selectedGenreId)) {
349             titleStyle = sProgramTitleStyle;
350             episodeStyle = sEpisodeTitleStyle;
351         }
352         SpannableStringBuilder description = new SpannableStringBuilder();
353         description.append(title);
354         if (!TextUtils.isEmpty(episode)) {
355             description.append('\n');
356 
357             // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for
358             // all lines. This is a non-printing character so it will not change the horizontal
359             // spacing however it will affect the line height. As we ensure the ZWJ has the same
360             // text style as the title it will make sure the line height is consistent.
361             description.append('\u200D');
362 
363             int middle = description.length();
364             description.append(episode);
365 
366             description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
367             description.setSpan(
368                     episodeStyle, middle, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
369         } else {
370             description.setSpan(
371                     titleStyle, 0, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
372         }
373         setText(description);
374     }
375 
updateIcons()376     private void updateIcons() {
377         // Sets recording icons if needed.
378         int iconResId = 0;
379         if (isEntryWideEnough() && mTableEntry.scheduledRecording != null) {
380             if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
381                 iconResId = R.drawable.ic_warning_white_18dp;
382             } else {
383                 switch (mTableEntry.scheduledRecording.getState()) {
384                     case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
385                         iconResId = R.drawable.ic_scheduled_recording;
386                         break;
387                     case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
388                         iconResId = R.drawable.ic_recording_program;
389                         break;
390                     default:
391                         // leave the iconResId=0
392                 }
393             }
394         }
395         setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0);
396         setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0);
397     }
398 
updateContentDescription(String title)399     private void updateContentDescription(String title) {
400         // The content description includes extra information that is displayed on the detail view
401         Resources resources = getResources();
402         String description = title;
403         // TODO(b/73282818): only say channel name when the row changes
404         Channel channel = mChannelDataManager.getChannel(mTableEntry.channelId);
405         if (channel != null) {
406             description = channel.getDisplayNumber() + " " + description;
407         }
408         description +=
409                 " "
410                         + Utils.getDurationString(
411                                 getContext(),
412                                 mClock,
413                                 mTableEntry.entryStartUtcMillis,
414                                 mTableEntry.entryEndUtcMillis,
415                                 true);
416         Program program = mTableEntry.program;
417         if (program != null) {
418             String episodeDescription = program.getEpisodeContentDescription(getContext());
419             if (!TextUtils.isEmpty(episodeDescription)) {
420                 description += " " + episodeDescription;
421             }
422         }
423         if (mTableEntry.scheduledRecording != null) {
424             if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
425                 description +=
426                         " " + resources.getString(R.string.dvr_epg_program_recording_conflict);
427             } else {
428                 switch (mTableEntry.scheduledRecording.getState()) {
429                     case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
430                         description +=
431                                 " "
432                                         + resources.getString(
433                                                 R.string.dvr_epg_program_recording_scheduled);
434                         break;
435                     case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
436                         description +=
437                                 " "
438                                         + resources.getString(
439                                                 R.string.dvr_epg_program_recording_in_progress);
440                         break;
441                     default:
442                         // do nothing
443                 }
444             }
445         }
446         if (mTableEntry.isBlocked()) {
447             description += " " + resources.getString(R.string.program_guide_content_locked);
448         } else if (program != null) {
449             String programDescription = program.getDescription();
450             if (!TextUtils.isEmpty(programDescription)) {
451                 description += " " + programDescription;
452             }
453         }
454         setContentDescription(description);
455     }
456 
457     /** Update programItemView to handle alignments of text. */
updateVisibleArea()458     public void updateVisibleArea() {
459         View parentView = ((View) getParent());
460         if (parentView == null) {
461             return;
462         }
463         if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
464             layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight());
465         } else {
466             layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft());
467         }
468     }
469 
470     /**
471      * Layout title and episode according to visible area.
472      *
473      * <p>Here's the spec. 1. Don't show text if it's shorter than 48dp. 2. Try showing whole text
474      * in visible area by placing and wrapping text, but do not wrap text less than 30min. 3.
475      * Episode title is visible only if title isn't multi-line.
476      *
477      * @param startOffset Offset of the start position from the enclosing view's start position.
478      * @param endOffset Offset of the end position from the enclosing view's end position.
479      */
layoutVisibleArea(int startOffset, int endOffset)480     private void layoutVisibleArea(int startOffset, int endOffset) {
481         int width = mTableEntry.getWidth();
482         int startPadding = Math.max(0, startOffset);
483         int endPadding = Math.max(0, endOffset);
484         int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
485         if (startPadding > 0 && width - startPadding < minWidth) {
486             startPadding = Math.max(0, width - minWidth);
487         }
488         if (endPadding > 0 && width - endPadding < minWidth) {
489             endPadding = Math.max(0, width - minWidth);
490         }
491 
492         if (startPadding + sItemPadding != getPaddingStart()
493                 || endPadding + sItemPadding != getPaddingEnd()) {
494             mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
495             setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0);
496             mPreventParentRelayout = false;
497         }
498     }
499 
clearValues()500     public void clearValues() {
501         if (getHandler() != null) {
502             getHandler().removeCallbacks(mUpdateFocus);
503         }
504 
505         setTag(null);
506         mProgramGuide = null;
507         mTableEntry = null;
508     }
509 
getProgress(Clock clock, long start, long end)510     private static int getProgress(Clock clock, long start, long end) {
511         long currentTime = clock.currentTimeMillis();
512         if (currentTime <= start) {
513             return 0;
514         } else if (currentTime >= end) {
515             return MAX_PROGRESS;
516         }
517         return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start));
518     }
519 
setProgress(Drawable drawable, int id, int progress)520     private static void setProgress(Drawable drawable, int id, int progress) {
521         if (drawable instanceof StateListDrawable) {
522             StateListDrawable stateDrawable = (StateListDrawable) drawable;
523             for (int i = 0; i < getStateCount(stateDrawable); ++i) {
524                 setProgress(getStateDrawable(stateDrawable, i), id, progress);
525             }
526         } else if (drawable instanceof LayerDrawable) {
527             LayerDrawable layerDrawable = (LayerDrawable) drawable;
528             for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) {
529                 setProgress(layerDrawable.getDrawable(i), id, progress);
530                 if (layerDrawable.getId(i) == id) {
531                     layerDrawable.getDrawable(i).setLevel(progress);
532                 }
533             }
534         }
535     }
536 
getStateCount(StateListDrawable stateListDrawable)537     private static int getStateCount(StateListDrawable stateListDrawable) {
538         try {
539             Object stateCount =
540                     StateListDrawable.class
541                             .getDeclaredMethod("getStateCount")
542                             .invoke(stateListDrawable);
543             return (int) stateCount;
544         } catch (NoSuchMethodException
545                 | IllegalAccessException
546                 | IllegalArgumentException
547                 | InvocationTargetException e) {
548             Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e);
549             return 0;
550         }
551     }
552 
getStateDrawable(StateListDrawable stateListDrawable, int index)553     private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) {
554         try {
555             Object drawable =
556                     StateListDrawable.class
557                             .getDeclaredMethod("getStateDrawable", Integer.TYPE)
558                             .invoke(stateListDrawable, index);
559             return (Drawable) drawable;
560         } catch (NoSuchMethodException
561                 | IllegalAccessException
562                 | IllegalArgumentException
563                 | InvocationTargetException e) {
564             Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e);
565             return null;
566         }
567     }
568 
569     @Override
requestLayout()570     public void requestLayout() {
571         if (mPreventParentRelayout) {
572             // Trivial layout, no need to tell parent.
573             forceLayout();
574         } else {
575             super.requestLayout();
576         }
577     }
578 }
579