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