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.os.SystemClock;
     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 import android.widget.Toast;
     38 
     39 import com.android.tv.ApplicationSingletons;
     40 import com.android.tv.MainActivity;
     41 import com.android.tv.R;
     42 import com.android.tv.TvApplication;
     43 import com.android.tv.analytics.Tracker;
     44 import com.android.tv.common.feature.CommonFeatures;
     45 import com.android.tv.data.Channel;
     46 import com.android.tv.dvr.DvrManager;
     47 import com.android.tv.dvr.DvrUiHelper;
     48 import com.android.tv.dvr.ScheduledRecording;
     49 import com.android.tv.guide.ProgramManager.TableEntry;
     50 import com.android.tv.util.ToastUtils;
     51 import com.android.tv.util.Utils;
     52 
     53 import java.lang.reflect.InvocationTargetException;
     54 import java.util.concurrent.TimeUnit;
     55 
     56 public class ProgramItemView extends TextView {
     57     private static final String TAG = "ProgramItemView";
     58 
     59     private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1);
     60     private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE
     61 
     62     // State indicating the focused program is the current program
     63     private static final int[] STATE_CURRENT_PROGRAM = { R.attr.state_current_program };
     64 
     65     // Workaround state in order to not use too much texture memory for RippleDrawable
     66     private static final int[] STATE_TOO_WIDE = { R.attr.state_program_too_wide };
     67 
     68     private static int sVisibleThreshold;
     69     private static int sItemPadding;
     70     private static int sCompoundDrawablePadding;
     71     private static TextAppearanceSpan sProgramTitleStyle;
     72     private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
     73     private static TextAppearanceSpan sEpisodeTitleStyle;
     74     private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
     75 
     76     private DvrManager mDvrManager;
     77     private TableEntry mTableEntry;
     78     private int mMaxWidthForRipple;
     79     private int mTextWidth;
     80 
     81     // If set this flag disables requests to re-layout the parent view as a result of changing
     82     // this view, improving performance. This also prevents the parent view to lose child focus
     83     // as a result of the re-layout (see b/21378855).
     84     private boolean mPreventParentRelayout;
     85 
     86     private static final View.OnClickListener ON_CLICKED = new View.OnClickListener() {
     87         @Override
     88         public void onClick(final View view) {
     89             TableEntry entry = ((ProgramItemView) view).mTableEntry;
     90             if (entry == null) {
     91                 //do nothing
     92                 return;
     93             }
     94             ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext());
     95             Tracker tracker = singletons.getTracker();
     96             tracker.sendEpgItemClicked();
     97             final MainActivity tvActivity = (MainActivity) view.getContext();
     98             final Channel channel = tvActivity.getChannelDataManager().getChannel(entry.channelId);
     99             if (entry.isCurrentProgram()) {
    100                 view.postDelayed(new Runnable() {
    101                     @Override
    102                     public void run() {
    103                         tvActivity.tuneToChannel(channel);
    104                         tvActivity.hideOverlaysForTune();
    105                     }
    106                 }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0
    107                         : view.getResources()
    108                                 .getInteger(R.integer.program_guide_ripple_anim_duration));
    109             } else if (CommonFeatures.DVR.isEnabled(view.getContext())) {
    110                 DvrManager dvrManager = singletons.getDvrManager();
    111                 if (entry.entryStartUtcMillis > System.currentTimeMillis()
    112                         && dvrManager.isProgramRecordable(entry.program)) {
    113                     if (entry.scheduledRecording == null) {
    114                         if (DvrUiHelper.checkStorageStatusAndShowErrorMessage(tvActivity,
    115                                 channel.getInputId())
    116                                 && DvrUiHelper.handleCreateSchedule(tvActivity, entry.program)) {
    117                             String msg = view.getContext().getString(
    118                                     R.string.dvr_msg_program_scheduled, entry.program.getTitle());
    119                             ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
    120                         }
    121                     } else {
    122                         dvrManager.removeScheduledRecording(entry.scheduledRecording);
    123                         String msg = view.getResources().getString(
    124                                 R.string.dvr_schedules_deletion_info, entry.program.getTitle());
    125                         ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
    126                     }
    127                 } else {
    128                     ToastUtils.show(view.getContext(), view.getResources()
    129                             .getString(R.string.dvr_msg_cannot_record_program), Toast.LENGTH_SHORT);
    130                 }
    131             }
    132         }
    133     };
    134 
    135     private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
    136             new View.OnFocusChangeListener() {
    137         @Override
    138         public void onFocusChange(View view, boolean hasFocus) {
    139             if (hasFocus) {
    140                 ((ProgramItemView) view).mUpdateFocus.run();
    141             } else {
    142                 Handler handler = view.getHandler();
    143                 if (handler != null) {
    144                     handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus);
    145                 }
    146             }
    147         }
    148     };
    149 
    150     private final Runnable mUpdateFocus = new Runnable() {
    151         @Override
    152         public void run() {
    153             refreshDrawableState();
    154             TableEntry entry = mTableEntry;
    155             if (entry == null) {
    156                 //do nothing
    157                 return;
    158             }
    159             if (entry.isCurrentProgram()) {
    160                 Drawable background = getBackground();
    161                 int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis);
    162                 setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress);
    163             }
    164             if (getHandler() != null) {
    165                 getHandler().postAtTime(this,
    166                         Utils.ceilTime(SystemClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY));
    167             }
    168         }
    169     };
    170 
    171     public ProgramItemView(Context context) {
    172         this(context, null);
    173     }
    174 
    175     public ProgramItemView(Context context, AttributeSet attrs) {
    176         this(context, attrs, 0);
    177     }
    178 
    179     public ProgramItemView(Context context, AttributeSet attrs, int defStyle) {
    180         super(context, attrs, defStyle);
    181         setOnClickListener(ON_CLICKED);
    182         setOnFocusChangeListener(ON_FOCUS_CHANGED);
    183         mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
    184     }
    185 
    186     private void initIfNeeded() {
    187         if (sVisibleThreshold != 0) {
    188             return;
    189         }
    190         Resources res = getContext().getResources();
    191 
    192         sVisibleThreshold = res.getDimensionPixelOffset(
    193                 R.dimen.program_guide_table_item_visible_threshold);
    194 
    195         sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
    196         sCompoundDrawablePadding = res.getDimensionPixelOffset(
    197                 R.dimen.program_guide_table_item_compound_drawable_padding);
    198 
    199         ColorStateList programTitleColor = ColorStateList.valueOf(res.getColor(
    200                 R.color.program_guide_table_item_program_title_text_color, null));
    201         ColorStateList grayedOutProgramTitleColor = res.getColorStateList(
    202                 R.color.program_guide_table_item_grayed_out_program_text_color, null);
    203         ColorStateList episodeTitleColor = ColorStateList.valueOf(res.getColor(
    204                 R.color.program_guide_table_item_program_episode_title_text_color, null));
    205         ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(res.getColor(
    206                 R.color.program_guide_table_item_grayed_out_program_episode_title_text_color,
    207                 null));
    208         int programTitleSize = res.getDimensionPixelSize(
    209                 R.dimen.program_guide_table_item_program_title_font_size);
    210         int episodeTitleSize = res.getDimensionPixelSize(
    211                 R.dimen.program_guide_table_item_program_episode_title_font_size);
    212 
    213         sProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor,
    214                 null);
    215         sGrayedOutProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize,
    216                 grayedOutProgramTitleColor, null);
    217         sEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor,
    218                 null);
    219         sGrayedOutEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize,
    220                 grayedOutEpisodeTitleColor, null);
    221     }
    222 
    223     @Override
    224     protected void onFinishInflate() {
    225         super.onFinishInflate();
    226         initIfNeeded();
    227     }
    228 
    229     @Override
    230     protected int[] onCreateDrawableState(int extraSpace) {
    231         if (mTableEntry != null) {
    232             int states[] = super.onCreateDrawableState(extraSpace
    233                     + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length);
    234             if (mTableEntry.isCurrentProgram()) {
    235                 mergeDrawableStates(states, STATE_CURRENT_PROGRAM);
    236             }
    237             if (mTableEntry.getWidth() > mMaxWidthForRipple) {
    238                 mergeDrawableStates(states, STATE_TOO_WIDE);
    239             }
    240             return states;
    241         }
    242         return super.onCreateDrawableState(extraSpace);
    243     }
    244 
    245     public TableEntry getTableEntry() {
    246         return mTableEntry;
    247     }
    248 
    249     @SuppressLint("SwitchIntDef")
    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             SpannableStringBuilder description = new SpannableStringBuilder();
    279             description.append(title);
    280             if (!TextUtils.isEmpty(episode)) {
    281                 description.append('\n');
    282 
    283                 // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for
    284                 // all lines. This is a non-printing character so it will not change the horizontal
    285                 // spacing however it will affect the line height. As we ensure the ZWJ has the same
    286                 // text style as the title it will make sure the line height is consistent.
    287                 description.append('\u200D');
    288 
    289                 int middle = description.length();
    290                 description.append(episode);
    291 
    292                 description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    293                 description.setSpan(episodeStyle, middle, description.length(),
    294                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    295             } else {
    296                 description.setSpan(titleStyle, 0, description.length(),
    297                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    298             }
    299             setText(description);
    300 
    301             // Sets recording icons if needed.
    302             int iconResId = 0;
    303             if (mTableEntry.scheduledRecording != null) {
    304                 if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
    305                     iconResId = R.drawable.ic_warning_white_18dp;
    306                 } else {
    307                     switch (mTableEntry.scheduledRecording.getState()) {
    308                         case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
    309                             iconResId = R.drawable.ic_scheduled_recording;
    310                             break;
    311                         case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
    312                             iconResId = R.drawable.ic_recording_program;
    313                             break;
    314                     }
    315                 }
    316             }
    317             setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0);
    318             setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0);
    319         }
    320         measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
    321         mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
    322         // Maximum width for us to use a ripple
    323         mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis);
    324     }
    325 
    326     /**
    327      * Update programItemView to handle alignments of text.
    328      */
    329     public void updateVisibleArea() {
    330         View parentView = ((View) getParent());
    331         if (parentView == null) {
    332             return;
    333         }
    334         if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
    335             layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight());
    336         } else  {
    337             layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft());
    338         }
    339     }
    340 
    341     /**
    342      * Layout title and episode according to visible area.
    343      *
    344      * Here's the spec.
    345      *   1. Don't show text if it's shorter than 48dp.
    346      *   2. Try showing whole text in visible area by placing and wrapping text,
    347      *      but do not wrap text less than 30min.
    348      *   3. Episode title is visible only if title isn't multi-line.
    349      *
    350      * @param startOffset Offset of the start position from the enclosing view's start position.
    351      * @param endOffset Offset of the end position from the enclosing view's end position.
    352      */
    353      private void layoutVisibleArea(int startOffset, int endOffset) {
    354         int width = mTableEntry.getWidth();
    355         int startPadding = Math.max(0, startOffset);
    356         int endPadding = Math.max(0, endOffset);
    357         int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
    358         if (startPadding > 0 && width - startPadding < minWidth) {
    359             startPadding = Math.max(0, width - minWidth);
    360         }
    361         if (endPadding > 0 && width - endPadding < minWidth) {
    362             endPadding = Math.max(0, width - minWidth);
    363         }
    364 
    365         if (startPadding + sItemPadding != getPaddingStart()
    366                 || endPadding + sItemPadding != getPaddingEnd()) {
    367             mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
    368             setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0);
    369             mPreventParentRelayout = false;
    370         }
    371     }
    372 
    373     public void clearValues() {
    374         if (getHandler() != null) {
    375             getHandler().removeCallbacks(mUpdateFocus);
    376         }
    377 
    378         setTag(null);
    379         mTableEntry = null;
    380     }
    381 
    382     private static int getProgress(long start, long end) {
    383         long currentTime = System.currentTimeMillis();
    384         if (currentTime <= start) {
    385             return 0;
    386         } else if (currentTime >= end) {
    387             return MAX_PROGRESS;
    388         }
    389         return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start));
    390     }
    391 
    392     private static void setProgress(Drawable drawable, int id, int progress) {
    393         if (drawable instanceof StateListDrawable) {
    394             StateListDrawable stateDrawable = (StateListDrawable) drawable;
    395             for (int i = 0; i < getStateCount(stateDrawable); ++i) {
    396                 setProgress(getStateDrawable(stateDrawable, i), id, progress);
    397             }
    398         } else if (drawable instanceof LayerDrawable) {
    399             LayerDrawable layerDrawable = (LayerDrawable) drawable;
    400             for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) {
    401                 setProgress(layerDrawable.getDrawable(i), id, progress);
    402                 if (layerDrawable.getId(i) == id) {
    403                     layerDrawable.getDrawable(i).setLevel(progress);
    404                 }
    405             }
    406         }
    407     }
    408 
    409     private static int getStateCount(StateListDrawable stateListDrawable) {
    410         try {
    411             Object stateCount = StateListDrawable.class.getDeclaredMethod("getStateCount")
    412                     .invoke(stateListDrawable);
    413             return (int) stateCount;
    414         } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
    415                 |InvocationTargetException e) {
    416             Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e);
    417             return 0;
    418         }
    419     }
    420 
    421     private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) {
    422         try {
    423             Object drawable = StateListDrawable.class
    424                     .getDeclaredMethod("getStateDrawable", Integer.TYPE)
    425                     .invoke(stateListDrawable, index);
    426             return (Drawable) drawable;
    427         } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
    428                 |InvocationTargetException e) {
    429             Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e);
    430             return null;
    431         }
    432     }
    433 
    434     @Override
    435     public void requestLayout() {
    436         if (mPreventParentRelayout) {
    437             // Trivial layout, no need to tell parent.
    438             forceLayout();
    439         } else {
    440             super.requestLayout();
    441         }
    442     }
    443 }
    444