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.content.Context;
20import android.content.res.ColorStateList;
21import android.content.res.Resources;
22import android.graphics.drawable.Drawable;
23import android.graphics.drawable.LayerDrawable;
24import android.graphics.drawable.StateListDrawable;
25import android.os.Handler;
26import android.os.SystemClock;
27import android.support.v4.os.BuildCompat;
28import android.text.SpannableStringBuilder;
29import android.text.Spanned;
30import android.text.TextUtils;
31import android.text.style.TextAppearanceSpan;
32import android.util.AttributeSet;
33import android.util.Log;
34import android.view.View;
35import android.view.ViewGroup;
36import android.widget.TextView;
37
38import com.android.tv.ApplicationSingletons;
39import com.android.tv.MainActivity;
40import com.android.tv.R;
41import com.android.tv.TvApplication;
42import com.android.tv.analytics.Tracker;
43import com.android.tv.common.feature.CommonFeatures;
44import com.android.tv.data.Channel;
45import com.android.tv.dvr.DvrManager;
46import com.android.tv.dvr.ui.DvrDialogFragment;
47import com.android.tv.dvr.ui.DvrRecordDeleteFragment;
48import com.android.tv.dvr.ui.DvrRecordScheduleFragment;
49import com.android.tv.guide.ProgramManager.TableEntry;
50import com.android.tv.util.Utils;
51
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 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
176    public ProgramItemView(Context context) {
177        this(context, null);
178    }
179
180    public ProgramItemView(Context context, AttributeSet attrs) {
181        this(context, attrs, 0);
182    }
183
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
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
225    protected void onFinishInflate() {
226        super.onFinishInflate();
227        initIfNeeded();
228    }
229
230    @Override
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
246    public TableEntry getTableEntry() {
247        return mTableEntry;
248    }
249
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     */
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
342    public void clearValues() {
343        if (getHandler() != null) {
344            getHandler().removeCallbacks(mUpdateFocus);
345        }
346
347        setTag(null);
348        mTableEntry = null;
349    }
350
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
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
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
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
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