• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tv.guide;
18 
19 import android.annotation.SuppressLint;
20 import android.content.Context;
21 import android.content.res.ColorStateList;
22 import android.content.res.Resources;
23 import android.graphics.drawable.Drawable;
24 import android.graphics.drawable.LayerDrawable;
25 import android.graphics.drawable.StateListDrawable;
26 import android.os.Handler;
27 import android.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 
ProgramItemView(Context context)171     public ProgramItemView(Context context) {
172         this(context, null);
173     }
174 
ProgramItemView(Context context, AttributeSet attrs)175     public ProgramItemView(Context context, AttributeSet attrs) {
176         this(context, attrs, 0);
177     }
178 
ProgramItemView(Context context, AttributeSet attrs, int defStyle)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 
initIfNeeded()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
onFinishInflate()224     protected void onFinishInflate() {
225         super.onFinishInflate();
226         initIfNeeded();
227     }
228 
229     @Override
onCreateDrawableState(int extraSpace)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 
getTableEntry()245     public TableEntry getTableEntry() {
246         return mTableEntry;
247     }
248 
249     @SuppressLint("SwitchIntDef")
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             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      */
updateVisibleArea()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      */
layoutVisibleArea(int startOffset, int endOffset)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 
clearValues()373     public void clearValues() {
374         if (getHandler() != null) {
375             getHandler().removeCallbacks(mUpdateFocus);
376         }
377 
378         setTag(null);
379         mTableEntry = null;
380     }
381 
getProgress(long start, long end)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 
setProgress(Drawable drawable, int id, int progress)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 
getStateCount(StateListDrawable stateListDrawable)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 
getStateDrawable(StateListDrawable stateListDrawable, int index)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
requestLayout()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