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.util.Range; 25 import android.view.View; 26 import android.view.ViewTreeObserver.OnGlobalLayoutListener; 27 28 import com.android.tv.MainActivity; 29 import com.android.tv.data.Channel; 30 import com.android.tv.guide.ProgramManager.TableEntry; 31 import com.android.tv.util.Utils; 32 33 import java.util.concurrent.TimeUnit; 34 35 public 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