Home | History | Annotate | Download | only in guide
      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 
    202     public ProgramItemView(Context context) {
    203         this(context, null);
    204     }
    205 
    206     public ProgramItemView(Context context, AttributeSet attrs) {
    207         this(context, attrs, 0);
    208     }
    209 
    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 
    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
    269     protected void onFinishInflate() {
    270         super.onFinishInflate();
    271         initIfNeeded();
    272     }
    273 
    274     @Override
    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 
    291     public TableEntry getTableEntry() {
    292         return mTableEntry;
    293     }
    294 
    295     @SuppressLint("SwitchIntDef")
    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 
    328     private boolean isEntryWideEnough() {
    329         return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold;
    330     }
    331 
    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 
    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 
    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. */
    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      */
    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 
    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 
    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 
    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 
    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 
    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
    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