ProgramItemView.java revision 0cc0713c1bf8027642987b750b80217569d2932a
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
17package com.android.tv.guide;
18
19import android.annotation.SuppressLint;
20import android.content.Context;
21import android.content.res.ColorStateList;
22import android.content.res.Resources;
23import android.graphics.drawable.Drawable;
24import android.graphics.drawable.LayerDrawable;
25import android.graphics.drawable.StateListDrawable;
26import android.os.Handler;
27import android.text.SpannableStringBuilder;
28import android.text.Spanned;
29import android.text.TextUtils;
30import android.text.style.TextAppearanceSpan;
31import android.util.AttributeSet;
32import android.util.Log;
33import android.view.View;
34import android.view.ViewGroup;
35import android.widget.TextView;
36import android.widget.Toast;
37import com.android.tv.MainActivity;
38import com.android.tv.R;
39import com.android.tv.TvSingletons;
40import com.android.tv.analytics.Tracker;
41import com.android.tv.common.feature.CommonFeatures;
42import com.android.tv.common.util.Clock;
43import com.android.tv.data.ChannelDataManager;
44import com.android.tv.data.Program;
45import com.android.tv.data.api.Channel;
46import com.android.tv.dvr.DvrManager;
47import com.android.tv.dvr.data.ScheduledRecording;
48import com.android.tv.dvr.ui.DvrUiHelper;
49import com.android.tv.guide.ProgramManager.TableEntry;
50import com.android.tv.util.ToastUtils;
51import com.android.tv.util.Utils;
52import java.lang.reflect.InvocationTargetException;
53import java.util.concurrent.TimeUnit;
54
55public 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