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