• 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.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 
ProgramItemView(Context context)176     public ProgramItemView(Context context) {
177         this(context, null);
178     }
179 
ProgramItemView(Context context, AttributeSet attrs)180     public ProgramItemView(Context context, AttributeSet attrs) {
181         this(context, attrs, 0);
182     }
183 
ProgramItemView(Context context, AttributeSet attrs, int defStyle)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 
initIfNeeded()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
onFinishInflate()225     protected void onFinishInflate() {
226         super.onFinishInflate();
227         initIfNeeded();
228     }
229 
230     @Override
onCreateDrawableState(int extraSpace)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 
getTableEntry()246     public TableEntry getTableEntry() {
247         return mTableEntry;
248     }
249 
setValues(TableEntry entry, int selectedGenreId, long fromUtcMillis, long toUtcMillis, String gapTitle)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      */
layoutVisibleArea(int offset)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 
clearValues()342     public void clearValues() {
343         if (getHandler() != null) {
344             getHandler().removeCallbacks(mUpdateFocus);
345         }
346 
347         setTag(null);
348         mTableEntry = null;
349     }
350 
getProgress(long start, long end)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 
setProgress(Drawable drawable, int id, int progress)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 
getStateCount(StateListDrawable stateListDrawable)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 
getStateDrawable(StateListDrawable stateListDrawable, int index)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
requestLayout()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