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 
17 package com.android.tv.guide;
18 
19 import android.content.Context;
20 import android.graphics.Rect;
21 import android.support.v7.widget.LinearLayoutManager;
22 import android.util.AttributeSet;
23 import android.util.Log;
24 import android.view.View;
25 import android.view.ViewParent;
26 import android.view.ViewTreeObserver;
27 
28 import com.android.tv.data.Channel;
29 import com.android.tv.guide.ProgramManager.TableEntry;
30 import com.android.tv.util.Utils;
31 
32 import java.util.concurrent.TimeUnit;
33 
34 public 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          */
onChildFocus(View oldFocus, View newFocus)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 
ProgramRow(Context context)67     public ProgramRow(Context context) {
68         this(context, null);
69     }
70 
ProgramRow(Context context, AttributeSet attrs)71     public ProgramRow(Context context, AttributeSet attrs) {
72         this(context, attrs, 0);
73     }
74 
ProgramRow(Context context, AttributeSet attrs, int defStyle)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      */
setChildFocusListener(ChildFocusListener childFocusListener)82     public void setChildFocusListener(ChildFocusListener childFocusListener) {
83         mChildFocusListener = childFocusListener;
84     }
85 
86     @Override
onScrolled(int dx, int dy)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      */
focusCurrentProgram()107     public void focusCurrentProgram() {
108         View currentProgram = getCurrentProgramView();
109         if (currentProgram == null) {
110             currentProgram = getChildAt(0);
111         }
112         updateCurrentFocus(null, currentProgram);
113     }
114 
updateCurrentFocus(View oldFocus, View newFocus)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 
isChild(View view)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.)
isDirectionStart(int direction)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.)
isDirectionEnd(int direction)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
focusSearch(View focused, int direction)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 
scrollByTime(long timeToScroll)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
onAttachedToWindow()211     protected void onAttachedToWindow() {
212         super.onAttachedToWindow();
213         getViewTreeObserver().addOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
214     }
215 
216     @Override
onDetachedFromWindow()217     protected void onDetachedFromWindow() {
218         super.onDetachedFromWindow();
219         getViewTreeObserver().removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
220     }
221 
222     @Override
onChildDetachedFromWindow(View child)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
onChildAttachedToWindow(View child)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
onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)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 
getCurrentProgramView()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 
setChannel(Channel channel)292     public void setChannel(Channel channel) {
293         mChannel = channel;
294     }
295 
296     /**
297      * Sets the instance of {@link ProgramManager}
298      */
setProgramManager(ProgramManager programManager)299     public void setProgramManager(ProgramManager programManager) {
300         mProgramManager = programManager;
301     }
302 
303     /**
304      * Resets the scroll with the initial offset {@code scrollOffset}.
305      */
resetScroll(int scrollOffset)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