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.graphics.Rect;
21import android.support.v7.widget.LinearLayoutManager;
22import android.util.AttributeSet;
23import android.util.Log;
24import android.util.Range;
25import android.view.View;
26import android.view.ViewTreeObserver.OnGlobalLayoutListener;
27
28import com.android.tv.MainActivity;
29import com.android.tv.data.Channel;
30import com.android.tv.guide.ProgramManager.TableEntry;
31import com.android.tv.util.Utils;
32
33import java.util.concurrent.TimeUnit;
34
35public class ProgramRow extends TimelineGridView {
36    private static final String TAG = "ProgramRow";
37    private static final boolean DEBUG = false;
38
39    private static final long ONE_HOUR_MILLIS = TimeUnit.HOURS.toMillis(1);
40    private static final long HALF_HOUR_MILLIS = ONE_HOUR_MILLIS / 2;
41
42    private ProgramGuide mProgramGuide;
43    private ProgramManager mProgramManager;
44
45    private boolean mKeepFocusToCurrentProgram;
46    private ChildFocusListener mChildFocusListener;
47
48    interface ChildFocusListener {
49        /**
50         * Is called after focus is moved. Caller should check if old and new focuses are
51         * listener's children.
52         * See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}.
53         */
54        void onChildFocus(View oldFocus, View newFocus);
55    }
56
57    /**
58     * Used only for debugging.
59     */
60    private Channel mChannel;
61
62    private final OnGlobalLayoutListener mLayoutListener = new OnGlobalLayoutListener() {
63        @Override
64        public void onGlobalLayout() {
65            getViewTreeObserver().removeOnGlobalLayoutListener(this);
66            updateChildVisibleArea();
67        }
68    };
69
70    public ProgramRow(Context context) {
71        this(context, null);
72    }
73
74    public ProgramRow(Context context, AttributeSet attrs) {
75        this(context, attrs, 0);
76    }
77
78    public ProgramRow(Context context, AttributeSet attrs, int defStyle) {
79        super(context, attrs, defStyle);
80    }
81
82    /**
83     * Registers a listener focus events occurring on children to the {@code ProgramRow}.
84     */
85    public void setChildFocusListener(ChildFocusListener childFocusListener) {
86        mChildFocusListener = childFocusListener;
87    }
88
89    @Override
90    public void onViewAdded(View child) {
91        super.onViewAdded(child);
92        ProgramItemView itemView = (ProgramItemView) child;
93        if (getLeft() <= itemView.getRight() && itemView.getLeft() <= getRight()) {
94            itemView.updateVisibleArea();
95        }
96    }
97
98    @Override
99    public void onScrolled(int dx, int dy) {
100        // Remove callback to prevent updateChildVisibleArea being called twice.
101        getViewTreeObserver().removeOnGlobalLayoutListener(mLayoutListener);
102        super.onScrolled(dx, dy);
103        if (DEBUG) {
104            Log.d(TAG, "onScrolled by " + dx);
105            Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + getChildCount());
106            Log.d(TAG, "ProgramRow {" + Utils.toRectString(this) + "}");
107        }
108        updateChildVisibleArea();
109    }
110
111    /**
112     * Moves focus to the current program.
113     */
114    public void focusCurrentProgram() {
115        View currentProgram = getCurrentProgramView();
116        if (currentProgram == null) {
117            currentProgram = getChildAt(0);
118        }
119        if (mChildFocusListener != null) {
120            mChildFocusListener.onChildFocus(null, currentProgram);
121        }
122    }
123
124    // Call this API after RTL is resolved. (i.e. View is measured.)
125    private boolean isDirectionStart(int direction) {
126        return getLayoutDirection() == LAYOUT_DIRECTION_LTR
127                ? direction == View.FOCUS_LEFT : direction == View.FOCUS_RIGHT;
128    }
129
130    // Call this API after RTL is resolved. (i.e. View is measured.)
131    private boolean isDirectionEnd(int direction) {
132        return getLayoutDirection() == LAYOUT_DIRECTION_LTR
133                ? direction == View.FOCUS_RIGHT : direction == View.FOCUS_LEFT;
134    }
135
136    @Override
137    public View focusSearch(View focused, int direction) {
138        TableEntry focusedEntry = ((ProgramItemView) focused).getTableEntry();
139        long fromMillis = mProgramManager.getFromUtcMillis();
140        long toMillis = mProgramManager.getToUtcMillis();
141
142        if (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD) {
143            if (focusedEntry.entryStartUtcMillis < fromMillis) {
144                // The current entry starts outside of the view; Align or scroll to the left.
145                scrollByTime(Math.max(-ONE_HOUR_MILLIS,
146                        focusedEntry.entryStartUtcMillis - fromMillis));
147                return focused;
148            }
149        } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
150            if (focusedEntry.entryEndUtcMillis >= toMillis + ONE_HOUR_MILLIS) {
151                // The current entry ends outside of the view; Scroll to the right.
152                scrollByTime(ONE_HOUR_MILLIS);
153                return focused;
154            }
155        }
156
157        View target = super.focusSearch(focused, direction);
158        if (!(target instanceof ProgramItemView)) {
159            if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
160                if (focusedEntry.entryEndUtcMillis != toMillis) {
161                    // The focused entry is the last entry; Align to the right edge.
162                    scrollByTime(focusedEntry.entryEndUtcMillis - toMillis);
163                    return focused;
164                }
165            }
166            return target;
167        }
168
169        TableEntry targetEntry = ((ProgramItemView) target).getTableEntry();
170
171        if (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD) {
172            if (targetEntry.entryStartUtcMillis < fromMillis &&
173                    targetEntry.entryEndUtcMillis < fromMillis + HALF_HOUR_MILLIS) {
174                // The target entry starts outside the view; Align or scroll to the left.
175                scrollByTime(Math.max(-ONE_HOUR_MILLIS,
176                        targetEntry.entryStartUtcMillis - fromMillis));
177            }
178        } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
179            if (targetEntry.entryStartUtcMillis > fromMillis + ONE_HOUR_MILLIS + HALF_HOUR_MILLIS) {
180                // The target entry starts outside the view; Align or scroll to the right.
181                scrollByTime(Math.min(ONE_HOUR_MILLIS,
182                        targetEntry.entryStartUtcMillis - fromMillis - ONE_HOUR_MILLIS));
183            }
184        }
185
186        return target;
187    }
188
189    private void scrollByTime(long timeToScroll) {
190        if (DEBUG) {
191            Log.d(TAG, "scrollByTime(timeToScroll="
192                    + TimeUnit.MILLISECONDS.toMinutes(timeToScroll) + "min)");
193        }
194        mProgramManager.shiftTime(timeToScroll);
195    }
196
197    @Override
198    public void onChildDetachedFromWindow(View child) {
199        if (child.hasFocus()) {
200            // Focused view can be detached only if it's updated.
201            TableEntry entry = ((ProgramItemView) child).getTableEntry();
202            if (entry.program == null) {
203                // The focus is lost due to information loaded. Requests focus immediately.
204                // (Because this entry is detached after real entries attached, we can't take
205                // the below approach to resume focus on entry being attached.)
206                post(new Runnable() {
207                    @Override
208                    public void run() {
209                        requestFocus();
210                    }
211                });
212            } else if (entry.isCurrentProgram()) {
213                if (DEBUG) Log.d(TAG, "Keep focus to the current program");
214                // Current program is visible in the guide.
215                // Updated entries including current program's will be attached again soon
216                // so give focus back in onChildAttachedToWindow().
217                mKeepFocusToCurrentProgram = true;
218            }
219        }
220        super.onChildDetachedFromWindow(child);
221    }
222
223    @Override
224    public void onChildAttachedToWindow(View child) {
225        super.onChildAttachedToWindow(child);
226        if (mKeepFocusToCurrentProgram) {
227            TableEntry entry = ((ProgramItemView) child).getTableEntry();
228            if (entry.isCurrentProgram()) {
229                mKeepFocusToCurrentProgram = false;
230                post(new Runnable() {
231                    @Override
232                    public void run() {
233                        requestFocus();
234                    }
235                });
236            }
237        }
238    }
239
240    @Override
241    public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
242        ProgramGrid programGrid = mProgramGuide.getProgramGrid();
243
244        // Give focus according to the previous focused range
245        Range<Integer> focusRange = programGrid.getFocusRange();
246        View nextFocus = GuideUtils.findNextFocusedProgram(this, focusRange.getLower(),
247                focusRange.getUpper(), programGrid.isKeepCurrentProgramFocused());
248
249        if (nextFocus != null) {
250            return nextFocus.requestFocus();
251        }
252
253        if (DEBUG) Log.d(TAG, "onRequestFocusInDescendants");
254        boolean result = super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
255        if (!result) {
256            // The default focus search logic of LeanbackLibrary is sometimes failed.
257            // As a fallback solution, we request focus to the first focusable view.
258            for (int i = 0; i < getChildCount(); ++i) {
259                View child = getChildAt(i);
260                if (child.isShown() && child.hasFocusable()) {
261                    return child.requestFocus();
262                }
263            }
264        }
265        return result;
266    }
267
268    private View getCurrentProgramView() {
269        for (int i = 0; i < getChildCount(); ++i) {
270            TableEntry entry = ((ProgramItemView) getChildAt(i)).getTableEntry();
271            if (entry.isCurrentProgram()) {
272                return getChildAt(i);
273            }
274        }
275        return null;
276    }
277
278    public void setChannel(Channel channel) {
279        mChannel = channel;
280    }
281
282    /**
283     * Sets the instance of {@link ProgramGuide}
284     */
285    public void setProgramGuide(ProgramGuide programGuide) {
286        mProgramGuide = programGuide;
287        mProgramManager = programGuide.getProgramManager();
288    }
289
290    /**
291     * Resets the scroll with the initial offset {@code scrollOffset}.
292     */
293    public void resetScroll(int scrollOffset) {
294        long startTime = GuideUtils.convertPixelToMillis(scrollOffset)
295                + mProgramManager.getStartTime();
296        int position = mChannel == null ? -1 : mProgramManager.getProgramIndexAtTime(
297                mChannel.getId(), startTime);
298        if (position < 0) {
299            getLayoutManager().scrollToPosition(0);
300        } else {
301            TableEntry entry = mProgramManager.getTableEntry(mChannel.getId(), position);
302            int offset = GuideUtils.convertMillisToPixel(
303                    mProgramManager.getStartTime(), entry.entryStartUtcMillis) - scrollOffset;
304            ((LinearLayoutManager) getLayoutManager())
305                    .scrollToPositionWithOffset(position, offset);
306            // Workaround to b/31598505. When a program's duration is too long,
307            // RecyclerView.onScrolled() will not be called after scrollToPositionWithOffset().
308            // Therefore we have to update children's visible areas by ourselves in this case.
309            // Since scrollToPositionWithOffset() will call requestLayout(), we can listen to this
310            // behavior to ensure program items' visible areas are correctly updated after layouts
311            // are adjusted, i.e., scrolling is over.
312            getViewTreeObserver().addOnGlobalLayoutListener(mLayoutListener);
313        }
314    }
315
316    private void updateChildVisibleArea() {
317        for (int i = 0; i < getChildCount(); ++i) {
318            ProgramItemView child = (ProgramItemView) getChildAt(i);
319            if (getLeft() < child.getRight() && child.getLeft() < getRight()) {
320                child.updateVisibleArea();
321            }
322        }
323    }
324}
325