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 */ 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