ProgramItemView.java revision ba5845f23b8fbc985890f892961abc8b39886611
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.app.AlertDialog;
20import android.content.Context;
21import android.content.DialogInterface;
22import android.content.res.ColorStateList;
23import android.content.res.Resources;
24import android.graphics.drawable.Drawable;
25import android.graphics.drawable.LayerDrawable;
26import android.graphics.drawable.StateListDrawable;
27import android.os.Handler;
28import android.os.SystemClock;
29import android.text.SpannableStringBuilder;
30import android.text.Spanned;
31import android.text.TextUtils;
32import android.text.style.TextAppearanceSpan;
33import android.util.AttributeSet;
34import android.util.Log;
35import android.view.View;
36import android.view.ViewGroup;
37import android.widget.TextView;
38
39import com.android.tv.ApplicationSingletons;
40import com.android.tv.MainActivity;
41import com.android.tv.R;
42import com.android.tv.TvApplication;
43import com.android.tv.analytics.Tracker;
44import com.android.tv.common.feature.CommonFeatures;
45import com.android.tv.data.Channel;
46import com.android.tv.data.Program;
47import com.android.tv.dvr.DvrManager;
48import com.android.tv.dvr.Recording;
49import com.android.tv.guide.ProgramManager.TableEntry;
50import com.android.tv.util.Utils;
51
52import java.lang.reflect.InvocationTargetException;
53import java.util.ArrayList;
54import java.util.List;
55import java.util.concurrent.TimeUnit;
56
57public class ProgramItemView extends TextView {
58    private static final String TAG = "ProgramItemView";
59
60    private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1);
61    private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE
62
63    private static final int ACTION_RECORD_PROGRAM = 100;
64    private static final int ACTION_RECORD_SEASON = 101;
65
66    // State indicating the focused program is the current program
67    private static final int[] STATE_CURRENT_PROGRAM = { R.attr.state_current_program };
68
69    // Workaround state in order to not use too much texture memory for RippleDrawable
70    private static final int[] STATE_TOO_WIDE = { R.attr.state_program_too_wide };
71
72    private static int sVisibleThreshold;
73    private static int sItemPadding;
74    private static TextAppearanceSpan sProgramTitleStyle;
75    private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
76    private static TextAppearanceSpan sEpisodeTitleStyle;
77    private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
78
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 = new View.OnClickListener() {
89        @Override
90        public void onClick(final View view) {
91            TableEntry entry = ((ProgramItemView) view).mTableEntry;
92            ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext());
93            Tracker tracker = singletons.getTracker();
94            tracker.sendEpgItemClicked();
95            if (entry.isCurrentProgram()) {
96                final MainActivity tvActivity = (MainActivity) view.getContext();
97                final Channel channel = tvActivity.getChannelDataManager()
98                        .getChannel(entry.channelId);
99                view.postDelayed(new Runnable() {
100                    @Override
101                    public void run() {
102                        tvActivity.tuneToChannel(channel);
103                        tvActivity.hideOverlaysForTune();
104                    }
105                }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0
106                        : view.getResources()
107                                .getInteger(R.integer.program_guide_ripple_anim_duration));
108            } else if (CommonFeatures.DVR.isEnabled(view.getContext())) {
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())) {
114                    showDvrDialog(view, entry, dvrManager);
115                }
116            }
117        }
118
119        private void showDvrDialog(final View view, TableEntry entry, final DvrManager dvrManager) {
120            List<CharSequence> items = new ArrayList<>();
121            final List<Integer> actions = new ArrayList<>();
122            // TODO: the items can be changed by the state of the program. For example,
123            // if the program is already added in scheduler, we need to show an item to
124            // delete the recording schedule.
125            items.add(view.getResources().getString(R.string.epg_dvr_record_program));
126            actions.add(ACTION_RECORD_PROGRAM);
127            items.add(view.getResources().getString(R.string.epg_dvr_record_season));
128            actions.add(ACTION_RECORD_SEASON);
129
130            final Program program = entry.program;
131            final List<Recording> conflicts = dvrManager
132                    .getScheduledRecordingsThatConflict(program);
133            // TODO: it is a tentative UI. Don't publish the UI.
134            DialogInterface.OnClickListener onClickListener
135                    = new DialogInterface.OnClickListener() {
136                @Override
137                public void onClick(final DialogInterface dialog, int which) {
138                    if (actions.get(which) == ACTION_RECORD_PROGRAM) {
139                        if (conflicts.isEmpty()) {
140                            dvrManager.addSchedule(program, conflicts);
141                        } else {
142                            showConflictDialog(view, dvrManager, program, conflicts);
143                        }
144                    } else if (actions.get(which) == ACTION_RECORD_SEASON) {
145                        dvrManager.addSeasonSchedule(program);
146                    }
147                    dialog.dismiss();
148                }
149            };
150            new AlertDialog.Builder(view.getContext())
151                    .setItems(items.toArray(new CharSequence[items.size()]), onClickListener)
152                    .create()
153                    .show();
154        }
155    };
156
157    private static void showConflictDialog(final View view, final DvrManager dvrManager,
158            final Program program, final List<Recording> conflicts) {
159        DialogInterface.OnClickListener conflictClickListener
160                = new DialogInterface.OnClickListener() {
161            @Override
162            public void onClick(DialogInterface dialog, int which) {
163                if (which == AlertDialog.BUTTON_POSITIVE) {
164                    dvrManager.addSchedule(program, conflicts);
165                    dialog.dismiss();
166                }
167            }
168        };
169        StringBuilder sb = new StringBuilder();
170        for (Recording r : conflicts) {
171            sb.append(r.toString()).append('\n');
172        }
173        new AlertDialog.Builder(view.getContext()).setTitle(R.string.dvr_epg_conflict_dialog_title)
174                .setMessage(sb.toString())
175                .setPositiveButton(R.string.dvr_epg_record, conflictClickListener)
176                .setNegativeButton(R.string.dvr_epg_do_not_record, conflictClickListener)
177                .create()
178                .show();
179    }
180
181    private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
182            new View.OnFocusChangeListener() {
183        @Override
184        public void onFocusChange(View view, boolean hasFocus) {
185            if (hasFocus) {
186                ((ProgramItemView) view).mUpdateFocus.run();
187            } else {
188                Handler handler = view.getHandler();
189                if (handler != null) {
190                    handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus);
191                }
192            }
193        }
194    };
195
196    private final Runnable mUpdateFocus = new Runnable() {
197        @Override
198        public void run() {
199            refreshDrawableState();
200            TableEntry entry = mTableEntry;
201            if (entry.isCurrentProgram()) {
202                Drawable background = getBackground();
203                int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis);
204                setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress);
205            }
206            if (getHandler() != null) {
207                getHandler().postAtTime(this,
208                        Utils.ceilTime(SystemClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY));
209            }
210        }
211    };
212
213    public ProgramItemView(Context context) {
214        this(context, null);
215    }
216
217    public ProgramItemView(Context context, AttributeSet attrs) {
218        this(context, attrs, 0);
219    }
220
221    public ProgramItemView(Context context, AttributeSet attrs, int defStyle) {
222        super(context, attrs, defStyle);
223    }
224
225    private void initIfNeeded() {
226        if (sVisibleThreshold != 0) {
227            return;
228        }
229        Resources res = getContext().getResources();
230
231        sVisibleThreshold = res.getDimensionPixelOffset(
232                R.dimen.program_guide_table_item_visible_threshold);
233
234        sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
235
236        ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res,
237                R.color.program_guide_table_item_program_title_text_color));
238        ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res,
239                R.color.program_guide_table_item_grayed_out_program_text_color);
240        ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
241                R.color.program_guide_table_item_program_episode_title_text_color));
242        ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
243                R.color.program_guide_table_item_grayed_out_program_episode_title_text_color));
244        int programTitleSize = res.getDimensionPixelSize(
245                R.dimen.program_guide_table_item_program_title_font_size);
246        int episodeTitleSize = res.getDimensionPixelSize(
247                R.dimen.program_guide_table_item_program_episode_title_font_size);
248
249        sProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor,
250                null);
251        sGrayedOutProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize,
252                grayedOutProgramTitleColor, null);
253        sEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor,
254                null);
255        sGrayedOutEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize,
256                grayedOutEpisodeTitleColor, null);
257    }
258
259    @Override
260    protected void onFinishInflate() {
261        super.onFinishInflate();
262        initIfNeeded();
263    }
264
265    @Override
266    protected int[] onCreateDrawableState(int extraSpace) {
267        if (mTableEntry != null) {
268            int states[] = super.onCreateDrawableState(extraSpace
269                    + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length);
270            if (mTableEntry.isCurrentProgram()) {
271                mergeDrawableStates(states, STATE_CURRENT_PROGRAM);
272            }
273            if (mTableEntry.getWidth() > mMaxWidthForRipple) {
274                mergeDrawableStates(states, STATE_TOO_WIDE);
275            }
276            return states;
277        }
278        return super.onCreateDrawableState(extraSpace);
279    }
280
281    public TableEntry getTableEntry() {
282        return mTableEntry;
283    }
284
285    public void onBind(TableEntry entry, ProgramListAdapter adapter) {
286        mTableEntry = entry;
287        setOnClickListener(ON_CLICKED);
288        setOnFocusChangeListener(ON_FOCUS_CHANGED);
289        ProgramManager programManager = adapter.getProgramManager();
290
291        ViewGroup.LayoutParams layoutParams = getLayoutParams();
292        layoutParams.width = entry.getWidth();
293        setLayoutParams(layoutParams);
294
295        String title = entry.program != null ? entry.program.getTitle() : null;
296        String episode = entry.program != null ?
297                entry.program.getEpisodeDisplayTitle(getContext()) : null;
298
299        TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle;
300        TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle;
301
302        if (entry.getWidth() < sVisibleThreshold) {
303            setText(null);
304        } else {
305            if (entry.isGap()) {
306                if (entry.isBlocked()) {
307                    title = adapter.getBlockedProgramTitle();
308                } else {
309                    title = adapter.getNoInfoProgramTitle();
310                }
311                episode = null;
312            } else if (entry.hasGenre(programManager.getSelectedGenreId())) {
313                titleStyle = sProgramTitleStyle;
314                episodeStyle = sEpisodeTitleStyle;
315            }
316
317            SpannableStringBuilder description = new SpannableStringBuilder();
318            description.append(title);
319            if (!TextUtils.isEmpty(episode)) {
320                description.append('\n');
321
322                // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for
323                // all lines. This is a non-printing character so it will not change the horizontal
324                // spacing however it will affect the line height. As we ensure the ZWJ has the same
325                // text style as the title it will make sure the line height is consistent.
326                description.append('\u200D');
327
328                int middle = description.length();
329                description.append(episode);
330
331                description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
332                description.setSpan(episodeStyle, middle, description.length(),
333                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
334            } else {
335                description.setSpan(titleStyle, 0, description.length(),
336                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
337            }
338            setText(description);
339        }
340        measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
341        mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
342        int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis);
343        int guideStart = GuideUtils.convertMillisToPixel(programManager.getFromUtcMillis());
344        layoutVisibleArea(guideStart - start);
345
346        // Maximum width for us to use a ripple
347        mMaxWidthForRipple = GuideUtils.convertMillisToPixel(
348                programManager.getFromUtcMillis(), programManager.getToUtcMillis());
349    }
350
351    /**
352     * Layout title and episode according to visible area.
353     *
354     * Here's the spec.
355     *   1. Don't show text if it's shorter than 48dp.
356     *   2. Try showing whole text in visible area by placing and wrapping text,
357     *      but do not wrap text less than 30min.
358     *   3. Episode title is visible only if title isn't multi-line.
359     *
360     * @param offset Offset of the start position from the enclosing view's start position.
361     */
362     public void layoutVisibleArea(int offset) {
363        int width = mTableEntry.getWidth();
364        int startPadding = Math.max(0, offset);
365        int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
366        if (startPadding > 0 && width - startPadding < minWidth) {
367            startPadding = Math.max(0, width - minWidth);
368        }
369
370        if (startPadding + sItemPadding != getPaddingStart()) {
371            mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
372            setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0);
373            mPreventParentRelayout = false;
374        }
375    }
376
377    public void onUnbind() {
378        if (getHandler() != null) {
379            getHandler().removeCallbacks(mUpdateFocus);
380        }
381
382        setTag(null);
383        setOnFocusChangeListener(null);
384        setOnClickListener(null);
385    }
386
387    private static int getProgress(long start, long end) {
388        long currentTime = System.currentTimeMillis();
389        if (currentTime <= start) {
390            return 0;
391        } else if (currentTime >= end) {
392            return MAX_PROGRESS;
393        }
394        return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start));
395    }
396
397    private static void setProgress(Drawable drawable, int id, int progress) {
398        if (drawable instanceof StateListDrawable) {
399            StateListDrawable stateDrawable = (StateListDrawable) drawable;
400            for (int i = 0; i < getStateCount(stateDrawable); ++i) {
401                setProgress(getStateDrawable(stateDrawable, i), id, progress);
402            }
403        } else if (drawable instanceof LayerDrawable) {
404            LayerDrawable layerDrawable = (LayerDrawable) drawable;
405            for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) {
406                setProgress(layerDrawable.getDrawable(i), id, progress);
407                if (layerDrawable.getId(i) == id) {
408                    layerDrawable.getDrawable(i).setLevel(progress);
409                }
410            }
411        }
412    }
413
414    private static int getStateCount(StateListDrawable stateListDrawable) {
415        try {
416            Object stateCount = StateListDrawable.class.getDeclaredMethod("getStateCount")
417                    .invoke(stateListDrawable);
418            return (int) stateCount;
419        } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
420                |InvocationTargetException e) {
421            Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e);
422            return 0;
423        }
424    }
425
426    private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) {
427        try {
428            Object drawable = StateListDrawable.class
429                    .getDeclaredMethod("getStateDrawable", Integer.TYPE)
430                    .invoke(stateListDrawable, index);
431            return (Drawable) drawable;
432        } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException
433                |InvocationTargetException e) {
434            Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e);
435            return null;
436        }
437    }
438
439    @Override
440    public void requestLayout() {
441        if (mPreventParentRelayout) {
442            // Trivial layout, no need to tell parent.
443            forceLayout();
444        } else {
445            super.requestLayout();
446        }
447    }
448}
449