Home | History | Annotate | Download | only in guide
      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