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