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