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.content.Context;
     20 import android.content.res.ColorStateList;
     21 import android.content.res.Resources;
     22 import android.graphics.drawable.Drawable;
     23 import android.graphics.drawable.LayerDrawable;
     24 import android.graphics.drawable.StateListDrawable;
     25 import android.os.Handler;
     26 import android.os.SystemClock;
     27 import android.support.v4.os.BuildCompat;
     28 import android.text.SpannableStringBuilder;
     29 import android.text.Spanned;
     30 import android.text.TextUtils;
     31 import android.text.style.TextAppearanceSpan;
     32 import android.util.AttributeSet;
     33 import android.util.Log;
     34 import android.view.View;
     35 import android.view.ViewGroup;
     36 import android.widget.TextView;
     37 
     38 import com.android.tv.ApplicationSingletons;
     39 import com.android.tv.MainActivity;
     40 import com.android.tv.R;
     41 import com.android.tv.TvApplication;
     42 import com.android.tv.analytics.Tracker;
     43 import com.android.tv.common.feature.CommonFeatures;
     44 import com.android.tv.data.Channel;
     45 import com.android.tv.dvr.DvrManager;
     46 import com.android.tv.dvr.ui.DvrDialogFragment;
     47 import com.android.tv.dvr.ui.DvrRecordDeleteFragment;
     48 import com.android.tv.dvr.ui.DvrRecordScheduleFragment;
     49 import com.android.tv.guide.ProgramManager.TableEntry;
     50 import com.android.tv.util.Utils;
     51 
     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 TextAppearanceSpan sProgramTitleStyle;
     70     private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
     71     private static TextAppearanceSpan sEpisodeTitleStyle;
     72     private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
     73 
     74     private TableEntry mTableEntry;
     75     private int mMaxWidthForRipple;
     76     private int mTextWidth;
     77 
     78     // If set this flag disables requests to re-layout the parent view as a result of changing
     79     // this view, improving performance. This also prevents the parent view to lose child focus
     80     // as a result of the re-layout (see b/21378855).
     81     private boolean mPreventParentRelayout;
     82 
     83     private static final View.OnClickListener ON_CLICKED = new View.OnClickListener() {
     84         @Override
     85         public void onClick(final View view) {
     86             TableEntry entry = ((ProgramItemView) view).mTableEntry;
     87             if (entry == null) {
     88                 //do nothing
     89                 return;
     90             }
     91             ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext());
     92             Tracker tracker = singletons.getTracker();
     93             tracker.sendEpgItemClicked();
     94             if (entry.isCurrentProgram()) {
     95                 final MainActivity tvActivity = (MainActivity) view.getContext();
     96                 final Channel channel = tvActivity.getChannelDataManager()
     97                         .getChannel(entry.channelId);
     98                 view.postDelayed(new Runnable() {
     99                     @Override
    100                     public void run() {
    101                         tvActivity.tuneToChannel(channel);
    102                         tvActivity.hideOverlaysForTune();
    103                     }
    104                 }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0
    105                         : view.getResources()
    106                                 .getInteger(R.integer.program_guide_ripple_anim_duration));
    107             } else if (CommonFeatures.DVR.isEnabled(view.getContext()) && BuildCompat
    108                     .isAtLeastN()) {
    109                 final MainActivity tvActivity = (MainActivity) view.getContext();
    110                 final DvrManager dvrManager = singletons.getDvrManager();
    111                 final Channel channel = tvActivity.getChannelDataManager()
    112                         .getChannel(entry.channelId);
    113                 if (dvrManager.canRecord(channel.getInputId()) && entry.program != null) {
    114                     if (entry.scheduledRecording == null) {
    115                         showDvrDialog(view, entry);
    116                     } else {
    117                         showRecordDeleteDialog(view, entry);
    118                     }
    119                 }
    120             }
    121         }
    122 
    123         private void showDvrDialog(final View view, TableEntry entry) {
    124             Utils.showToastMessageForDeveloperFeature(view.getContext());
    125             DvrRecordScheduleFragment dvrRecordScheduleFragment =
    126                     new DvrRecordScheduleFragment(entry);
    127             DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(dvrRecordScheduleFragment);
    128             ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment(
    129                     DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true);
    130         }
    131 
    132         private void showRecordDeleteDialog(final View view, final TableEntry entry) {
    133             DvrRecordDeleteFragment recordDeleteDialogFragment = new DvrRecordDeleteFragment(entry);
    134             DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(recordDeleteDialogFragment);
    135             ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment(
    136                     DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true);
    137         }
    138     };
    139 
    140     private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
    141             new View.OnFocusChangeListener() {
    142         @Override
    143         public void onFocusChange(View view, boolean hasFocus) {
    144             if (hasFocus) {
    145                 ((ProgramItemView) view).mUpdateFocus.run();
    146             } else {
    147                 Handler handler = view.getHandler();
    148                 if (handler != null) {
    149                     handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus);
    150                 }
    151             }
    152         }
    153     };
    154 
    155     private final Runnable mUpdateFocus = new Runnable() {
    156         @Override
    157         public void run() {
    158             refreshDrawableState();
    159             TableEntry entry = mTableEntry;
    160             if (entry == null) {
    161                 //do nothing
    162                 return;
    163             }
    164             if (entry.isCurrentProgram()) {
    165                 Drawable background = getBackground();
    166                 int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis);
    167                 setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress);
    168             }
    169             if (getHandler() != null) {
    170                 getHandler().postAtTime(this,
    171                         Utils.ceilTime(SystemClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY));
    172             }
    173         }
    174     };
    175 
    176     public ProgramItemView(Context context) {
    177         this(context, null);
    178     }
    179 
    180     public ProgramItemView(Context context, AttributeSet attrs) {
    181         this(context, attrs, 0);
    182     }
    183 
    184     public ProgramItemView(Context context, AttributeSet attrs, int defStyle) {
    185         super(context, attrs, defStyle);
    186         setOnClickListener(ON_CLICKED);
    187         setOnFocusChangeListener(ON_FOCUS_CHANGED);
    188     }
    189 
    190     private void initIfNeeded() {
    191         if (sVisibleThreshold != 0) {
    192             return;
    193         }
    194         Resources res = getContext().getResources();
    195 
    196         sVisibleThreshold = res.getDimensionPixelOffset(
    197                 R.dimen.program_guide_table_item_visible_threshold);
    198 
    199         sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
    200 
    201         ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res,
    202                 R.color.program_guide_table_item_program_title_text_color));
    203         ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res,
    204                 R.color.program_guide_table_item_grayed_out_program_text_color);
    205         ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
    206                 R.color.program_guide_table_item_program_episode_title_text_color));
    207         ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
    208                 R.color.program_guide_table_item_grayed_out_program_episode_title_text_color));
    209         int programTitleSize = res.getDimensionPixelSize(
    210                 R.dimen.program_guide_table_item_program_title_font_size);
    211         int episodeTitleSize = res.getDimensionPixelSize(
    212                 R.dimen.program_guide_table_item_program_episode_title_font_size);
    213 
    214         sProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor,
    215                 null);
    216         sGrayedOutProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize,
    217                 grayedOutProgramTitleColor, null);
    218         sEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor,
    219                 null);
    220         sGrayedOutEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize,
    221                 grayedOutEpisodeTitleColor, null);
    222     }
    223 
    224     @Override
    225     protected void onFinishInflate() {
    226         super.onFinishInflate();
    227         initIfNeeded();
    228     }
    229 
    230     @Override
    231     protected int[] onCreateDrawableState(int extraSpace) {
    232         if (mTableEntry != null) {
    233             int states[] = super.onCreateDrawableState(extraSpace
    234                     + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length);
    235             if (mTableEntry.isCurrentProgram()) {
    236                 mergeDrawableStates(states, STATE_CURRENT_PROGRAM);
    237             }
    238             if (mTableEntry.getWidth() > mMaxWidthForRipple) {
    239                 mergeDrawableStates(states, STATE_TOO_WIDE);
    240             }
    241             return states;
    242         }
    243         return super.onCreateDrawableState(extraSpace);
    244     }
    245 
    246     public TableEntry getTableEntry() {
    247         return mTableEntry;
    248     }
    249 
    250     public void setValues(TableEntry entry, int selectedGenreId, long fromUtcMillis,
    251             long toUtcMillis, String gapTitle) {
    252         mTableEntry = entry;
    253 
    254         ViewGroup.LayoutParams layoutParams = getLayoutParams();
    255         layoutParams.width = entry.getWidth();
    256         setLayoutParams(layoutParams);
    257 
    258         String title = entry.program != null ? entry.program.getTitle() : null;
    259         String episode = entry.program != null ?
    260                 entry.program.getEpisodeDisplayTitle(getContext()) : null;
    261 
    262         TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle;
    263         TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle;
    264 
    265         if (entry.getWidth() < sVisibleThreshold) {
    266             setText(null);
    267         } else {
    268             if (entry.isGap()) {
    269                 title = gapTitle;
    270                 episode = null;
    271             } else if (entry.hasGenre(selectedGenreId)) {
    272                 titleStyle = sProgramTitleStyle;
    273                 episodeStyle = sEpisodeTitleStyle;
    274             }
    275             if (TextUtils.isEmpty(title)) {
    276                 title = getResources().getString(R.string.program_title_for_no_information);
    277             }
    278             if (mTableEntry.scheduledRecording != null) {
    279                 //TODO(dvr): use a proper icon for UI status.
    280                 title = "" + title;
    281             }
    282 
    283             SpannableStringBuilder description = new SpannableStringBuilder();
    284             description.append(title);
    285             if (!TextUtils.isEmpty(episode)) {
    286                 description.append('\n');
    287 
    288                 // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for
    289                 // all lines. This is a non-printing character so it will not change the horizontal
    290                 // spacing however it will affect the line height. As we ensure the ZWJ has the same
    291                 // text style as the title it will make sure the line height is consistent.
    292                 description.append('\u200D');
    293 
    294                 int middle = description.length();
    295                 description.append(episode);
    296 
    297                 description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    298                 description.setSpan(episodeStyle, middle, description.length(),
    299                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    300             } else {
    301                 description.setSpan(titleStyle, 0, description.length(),
    302                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    303             }
    304             setText(description);
    305         }
    306         measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
    307         mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
    308         int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis);
    309         int guideStart = GuideUtils.convertMillisToPixel(fromUtcMillis);
    310         layoutVisibleArea(guideStart - start);
    311 
    312         // Maximum width for us to use a ripple
    313         mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis);
    314     }
    315 
    316     /**
    317      * Layout title and episode according to visible area.
    318      *
    319      * Here's the spec.
    320      *   1. Don't show text if it's shorter than 48dp.
    321      *   2. Try showing whole text in visible area by placing and wrapping text,
    322      *      but do not wrap text less than 30min.
    323      *   3. Episode title is visible only if title isn't multi-line.
    324      *
    325      * @param offset Offset of the start position from the enclosing view's start position.
    326      */
    327      public void layoutVisibleArea(int offset) {
    328         int width = mTableEntry.getWidth();
    329         int startPadding = Math.max(0, offset);
    330         int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
    331         if (startPadding > 0 && width - startPadding < minWidth) {
    332             startPadding = Math.max(0, width - minWidth);
    333         }
    334 
    335         if (startPadding + sItemPadding != getPaddingStart()) {
    336             mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
    337             setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0);
    338             mPreventParentRelayout = false;
    339         }
    340     }
    341 
    342     public void clearValues() {
    343         if (getHandler() != null) {
    344             getHandler().removeCallbacks(mUpdateFocus);
    345         }
    346 
    347         setTag(null);
    348         mTableEntry = null;
    349     }
    350 
    351     private static int getProgress(long start, long end) {
    352         long currentTime = System.currentTimeMillis();
    353         if (currentTime <= start) {
    354             return 0;
    355         } else if (currentTime >= end) {
    356             return MAX_PROGRESS;
    357         }
    358         return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start));
    359     }
    360 
    361     private static void setProgress(Drawable drawable, int id, int progress) {
    362         if (drawable instanceof StateListDrawable) {
    363             StateListDrawable stateDrawable = (StateListDrawable) drawable;
    364             for (int i = 0; i < getStateCount(stateDrawable); ++i) {
    365                 setProgress(getStateDrawable(stateDrawable, i), id, progress);
    366             }
    367         } else if (drawable instanceof LayerDrawable) {
    368             LayerDrawable layerDrawable = (LayerDrawable) drawable;
    369             for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) {
    370                 setProgress(layerDrawable.getDrawable(i), id, progress);
    371                 if (layerDrawable.getId(i) == id) {
    372                     layerDrawable.getDrawable(i).setLevel(progress);
    373                 }
    374             }
    375         }
    376     }
    377 
    378     private static int getStateCount(StateListDrawable stateListDrawable) {
    379         try {
    380             Object stateCount = StateListDrawable.class.getDeclaredMethod("getStateCount")
    381                     .invoke(stateListDrawable);
    382             return (int) stateCount;
    383         } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
    384                 |InvocationTargetException e) {
    385             Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e);
    386             return 0;
    387         }
    388     }
    389 
    390     private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) {
    391         try {
    392             Object drawable = StateListDrawable.class
    393                     .getDeclaredMethod("getStateDrawable", Integer.TYPE)
    394                     .invoke(stateListDrawable, index);
    395             return (Drawable) drawable;
    396         } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
    397                 |InvocationTargetException e) {
    398             Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e);
    399             return null;
    400         }
    401     }
    402 
    403     @Override
    404     public void requestLayout() {
    405         if (mPreventParentRelayout) {
    406             // Trivial layout, no need to tell parent.
    407             forceLayout();
    408         } else {
    409             super.requestLayout();
    410         }
    411     }
    412 }
    413