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.animation.Animator;
20 import android.animation.AnimatorInflater;
21 import android.animation.AnimatorListenerAdapter;
22 import android.animation.AnimatorSet;
23 import android.animation.ObjectAnimator;
24 import android.animation.PropertyValuesHolder;
25 import android.content.Context;
26 import android.content.SharedPreferences;
27 import android.content.res.Resources;
28 import android.graphics.Point;
29 import android.os.Handler;
30 import android.os.Message;
31 import android.os.SystemClock;
32 import android.preference.PreferenceManager;
33 import android.support.annotation.NonNull;
34 import android.support.annotation.Nullable;
35 import androidx.recyclerview.widget.RecyclerView;
36 import android.util.Log;
37 import android.view.View;
38 import android.view.View.MeasureSpec;
39 import android.view.ViewGroup;
40 import android.view.ViewGroup.LayoutParams;
41 import android.view.ViewTreeObserver;
42 import android.view.accessibility.AccessibilityManager;
43 import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
44 
45 import androidx.leanback.widget.OnChildSelectedListener;
46 import androidx.leanback.widget.SearchOrbView;
47 import androidx.leanback.widget.VerticalGridView;
48 
49 import com.android.tv.ChannelTuner;
50 import com.android.tv.MainActivity;
51 import com.android.tv.R;
52 import com.android.tv.TvSingletons;
53 import com.android.tv.analytics.Tracker;
54 import com.android.tv.common.WeakHandler;
55 import com.android.tv.common.util.DurationTimer;
56 import com.android.tv.data.ChannelDataManager;
57 import com.android.tv.data.GenreItems;
58 import com.android.tv.data.ProgramDataManager;
59 import com.android.tv.dvr.DvrDataManager;
60 import com.android.tv.dvr.DvrScheduleManager;
61 import com.android.tv.features.TvFeatures;
62 import com.android.tv.perf.EventNames;
63 import com.android.tv.perf.PerformanceMonitor;
64 import com.android.tv.perf.TimerEvent;
65 import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter;
66 import com.android.tv.ui.ViewUtils;
67 import com.android.tv.ui.hideable.AutoHideScheduler;
68 import com.android.tv.util.TvInputManagerHelper;
69 import com.android.tv.util.Utils;
70 
71 import com.android.tv.common.flags.UiFlags;
72 
73 import java.util.ArrayList;
74 import java.util.List;
75 import java.util.concurrent.TimeUnit;
76 
77 /** The program guide. */
78 public class ProgramGuide
79         implements ProgramGrid.ChildFocusListener, AccessibilityStateChangeListener {
80     private static final String TAG = "ProgramGuide";
81     private static final boolean DEBUG = false;
82 
83     // Whether we should show the guide partially. The first time the user enters the program guide,
84     // we show the grid partially together with the genre side panel on the left. Next time
85     // the program guide is entered, we recover the previous state (partial or full).
86     private static final String KEY_SHOW_GUIDE_PARTIAL = "show_guide_partial";
87     private static final long TIME_INDICATOR_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1);
88     private static final long HOUR_IN_MILLIS = TimeUnit.HOURS.toMillis(1);
89     private static final long HALF_HOUR_IN_MILLIS = HOUR_IN_MILLIS / 2;
90     // We keep the duration between mStartTime and the current time larger than this value.
91     // We clip out the first program entry in ProgramManager, if it does not have enough width.
92     // In order to prevent from clipping out the current program, this value need be larger than
93     // or equal to ProgramManager.FIRST_ENTRY_MIN_DURATION.
94     private static final long MIN_DURATION_FROM_START_TIME_TO_CURRENT_TIME =
95             ProgramManager.FIRST_ENTRY_MIN_DURATION;
96 
97     private static final int MSG_PROGRAM_TABLE_FADE_IN_ANIM = 1000;
98 
99     private static final String SCREEN_NAME = "EPG";
100 
101     private final MainActivity mActivity;
102     private final ProgramManager mProgramManager;
103     private final AccessibilityManager mAccessibilityManager;
104     private final ChannelTuner mChannelTuner;
105     private final Tracker mTracker;
106     private final DurationTimer mVisibleDuration = new DurationTimer();
107     private final Runnable mPreShowRunnable;
108     private final Runnable mPostHideRunnable;
109 
110     private final int mWidthPerHour;
111     private final long mViewPortMillis;
112     private final int mRowHeight;
113     private final int mDetailHeight;
114     private final int mSelectionRow; // Row that is focused
115     private final int mTableFadeAnimDuration;
116     private final int mAnimationDuration;
117     private final int mDetailPadding;
118     private final SearchOrbView mSearchOrb;
119     private final UiFlags mUiFlags;
120     private int mCurrentTimeIndicatorWidth;
121 
122     private final View mContainer;
123     private final View mSidePanel;
124     private final VerticalGridView mSidePanelGridView;
125     private final View mTable;
126     private final TimelineRow mTimelineRow;
127     private final ProgramGrid mGrid;
128     private final TimeListAdapter mTimeListAdapter;
129     private final View mCurrentTimeIndicator;
130 
131     private final Animator mShowAnimatorFull;
132     private final Animator mShowAnimatorPartial;
133     // mHideAnimatorFull and mHideAnimatorPartial are created from the same animation xmls.
134     // When we share the one animator for two different animations, the starting value
135     // is broken, even though the starting value is not defined in XML.
136     private final Animator mHideAnimatorFull;
137     private final Animator mHideAnimatorPartial;
138     private final Animator mPartialToFullAnimator;
139     private final Animator mFullToPartialAnimator;
140     private final Animator mProgramTableFadeOutAnimator;
141     private final Animator mProgramTableFadeInAnimator;
142 
143     // When the program guide is popped up, we keep the previous state of the guide.
144     private boolean mShowGuidePartial;
145     private final SharedPreferences mSharedPreference;
146     private View mSelectedRow;
147     private Animator mDetailOutAnimator;
148     private Animator mDetailInAnimator;
149 
150     private long mStartUtcTime;
151     private boolean mTimelineAnimation;
152     private int mLastRequestedGenreId = GenreItems.ID_ALL_CHANNELS;
153     private boolean mIsDuringResetRowSelection;
154     private final Handler mHandler = new ProgramGuideHandler(this);
155     private boolean mActive;
156 
157     private final AutoHideScheduler mAutoHideScheduler;
158     private final long mShowDurationMillis;
159     private ViewTreeObserver.OnGlobalLayoutListener mOnLayoutListenerForShow;
160 
161     private final ProgramManagerListener mProgramManagerListener = new ProgramManagerListener();
162 
163     private final PerformanceMonitor mPerformanceMonitor;
164     private TimerEvent mTimerEvent;
165 
166     private final Runnable mUpdateTimeIndicator =
167             new Runnable() {
168                 @Override
169                 public void run() {
170                     positionCurrentTimeIndicator();
171                     mHandler.postAtTime(
172                             this,
173                             Utils.ceilTime(
174                                     SystemClock.uptimeMillis(), TIME_INDICATOR_UPDATE_FREQUENCY));
175                 }
176             };
177 
178     @SuppressWarnings("RestrictTo")
ProgramGuide( MainActivity activity, ChannelTuner channelTuner, TvInputManagerHelper tvInputManagerHelper, ChannelDataManager channelDataManager, ProgramDataManager programDataManager, @Nullable DvrDataManager dvrDataManager, @Nullable DvrScheduleManager dvrScheduleManager, Tracker tracker, Runnable preShowRunnable, Runnable postHideRunnable)179     public ProgramGuide(
180             MainActivity activity,
181             ChannelTuner channelTuner,
182             TvInputManagerHelper tvInputManagerHelper,
183             ChannelDataManager channelDataManager,
184             ProgramDataManager programDataManager,
185             @Nullable DvrDataManager dvrDataManager,
186             @Nullable DvrScheduleManager dvrScheduleManager,
187             Tracker tracker,
188             Runnable preShowRunnable,
189             Runnable postHideRunnable) {
190         mActivity = activity;
191         TvSingletons singletons = TvSingletons.getSingletons(mActivity);
192         mPerformanceMonitor = singletons.getPerformanceMonitor();
193         mUiFlags = singletons.getUiFlags();
194         mProgramManager =
195                 new ProgramManager(
196                         tvInputManagerHelper,
197                         channelDataManager,
198                         programDataManager,
199                         dvrDataManager,
200                         dvrScheduleManager);
201         mChannelTuner = channelTuner;
202         mTracker = tracker;
203         mPreShowRunnable = preShowRunnable;
204         mPostHideRunnable = postHideRunnable;
205 
206         Resources res = activity.getResources();
207 
208         mWidthPerHour = res.getDimensionPixelSize(R.dimen.program_guide_table_width_per_hour);
209         GuideUtils.setWidthPerHour(mWidthPerHour);
210 
211         Point displaySize = new Point();
212         mActivity.getWindowManager().getDefaultDisplay().getSize(displaySize);
213         int gridWidth =
214                 displaySize.x
215                         - res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_start)
216                         - res.getDimensionPixelSize(
217                                 R.dimen.program_guide_table_header_column_width);
218         mViewPortMillis = (gridWidth * HOUR_IN_MILLIS) / mWidthPerHour;
219 
220         mRowHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_item_row_height);
221         mDetailHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_detail_height);
222         mSelectionRow = res.getInteger(R.integer.program_guide_selection_row);
223         mTableFadeAnimDuration =
224                 res.getInteger(R.integer.program_guide_table_detail_fade_anim_duration);
225         mShowDurationMillis = res.getInteger(R.integer.program_guide_show_duration);
226         mAnimationDuration =
227                 res.getInteger(R.integer.program_guide_table_detail_toggle_anim_duration);
228         mDetailPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_detail_padding);
229 
230         mContainer = mActivity.findViewById(R.id.program_guide);
231         ViewTreeObserver.OnGlobalFocusChangeListener globalFocusChangeListener =
232                 new GlobalFocusChangeListener();
233         mContainer.getViewTreeObserver().addOnGlobalFocusChangeListener(globalFocusChangeListener);
234 
235         GenreListAdapter genreListAdapter = new GenreListAdapter(mActivity, mProgramManager, this);
236         mSidePanel = mContainer.findViewById(R.id.program_guide_side_panel);
237         mSidePanelGridView =
238                 (VerticalGridView) mContainer.findViewById(R.id.program_guide_side_panel_grid_view);
239         mSidePanelGridView
240                 .getRecycledViewPool()
241                 .setMaxRecycledViews(
242                         R.layout.program_guide_side_panel_row,
243                         res.getInteger(R.integer.max_recycled_view_pool_epg_side_panel_row));
244         mSidePanelGridView.setAdapter(genreListAdapter);
245         mSidePanelGridView.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_NO_EDGE);
246         mSidePanelGridView.setWindowAlignmentOffset(
247                 mActivity
248                         .getResources()
249                         .getDimensionPixelOffset(R.dimen.program_guide_side_panel_alignment_y));
250         mSidePanelGridView.setWindowAlignmentOffsetPercent(
251                 VerticalGridView.WINDOW_ALIGN_OFFSET_PERCENT_DISABLED);
252 
253         if (TvFeatures.EPG_SEARCH.isEnabled(mActivity)) {
254             mSearchOrb =
255                     (SearchOrbView)
256                             mContainer.findViewById(R.id.program_guide_side_panel_search_orb);
257             mSearchOrb.setVisibility(View.VISIBLE);
258 
259             mSearchOrb.setOnOrbClickedListener(
260                     new View.OnClickListener() {
261                         @Override
262                         public void onClick(View view) {
263                             hide();
264                             mActivity.showProgramGuideSearchFragment();
265                         }
266                     });
267             mSidePanelGridView.setOnChildSelectedListener(
268                     new androidx.leanback.widget.OnChildSelectedListener() {
269                         @Override
270                         public void onChildSelected(ViewGroup viewGroup, View view, int i, long l) {
271                             mSearchOrb.animate().alpha(i == 0 ? 1.0f : 0.0f);
272                         }
273                     });
274         } else {
275             mSearchOrb = null;
276         }
277 
278         mTable = mContainer.findViewById(R.id.program_guide_table);
279 
280         mTimelineRow = (TimelineRow) mTable.findViewById(R.id.time_row);
281         mTimeListAdapter = new TimeListAdapter(res);
282         mTimelineRow
283                 .getRecycledViewPool()
284                 .setMaxRecycledViews(
285                         R.layout.program_guide_table_header_row_item,
286                         res.getInteger(R.integer.max_recycled_view_pool_epg_header_row_item));
287         mTimelineRow.setAdapter(mTimeListAdapter);
288 
289         ProgramTableAdapter programTableAdapter =
290                 new ProgramTableAdapter(mActivity, this, mUiFlags);
291         programTableAdapter.registerAdapterDataObserver(
292                 new RecyclerView.AdapterDataObserver() {
293                     @Override
294                     public void onChanged() {
295                         // It is usually called when Genre is changed.
296                         // Reset selection of ProgramGrid
297                         resetRowSelection();
298                         updateGuidePosition();
299                     }
300                 });
301 
302         mGrid = (ProgramGrid) mTable.findViewById(R.id.grid);
303         mGrid.initialize(mProgramManager);
304         mGrid.getRecycledViewPool()
305                 .setMaxRecycledViews(
306                         R.layout.program_guide_table_row,
307                         res.getInteger(R.integer.max_recycled_view_pool_epg_table_row));
308         mGrid.setAdapter(programTableAdapter);
309 
310         mGrid.setChildFocusListener(this);
311         mGrid.setOnChildSelectedListener(
312                 new OnChildSelectedListener() {
313                     @Override
314                     public void onChildSelected(
315                             ViewGroup parent, View view, int position, long id) {
316                         if (mIsDuringResetRowSelection) {
317                             // Ignore if it's during the first resetRowSelection, because
318                             // onChildSelected
319                             // will be called again when rows are bound to the program table. if
320                             // selectRow
321                             // is called here, mSelectedRow is set and the second selectRow call
322                             // doesn't
323                             // work as intended.
324                             mIsDuringResetRowSelection = false;
325                             return;
326                         }
327                         selectRow(view);
328                     }
329                 });
330         mGrid.setFocusScrollStrategy(ProgramGrid.FOCUS_SCROLL_ALIGNED);
331         mGrid.setWindowAlignmentOffset(mSelectionRow * mRowHeight);
332         mGrid.setWindowAlignmentOffsetPercent(ProgramGrid.WINDOW_ALIGN_OFFSET_PERCENT_DISABLED);
333         mGrid.setItemAlignmentOffset(0);
334         mGrid.setItemAlignmentOffsetPercent(ProgramGrid.ITEM_ALIGN_OFFSET_PERCENT_DISABLED);
335 
336         mGrid.addOnScrollListener(
337                 new RecyclerView.OnScrollListener() {
338                     @Override
339                     public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
340                         if (DEBUG) {
341                             Log.d(TAG, "ProgramGrid onScrollStateChanged. newState=" + newState);
342                         }
343                         if (newState == RecyclerView.SCROLL_STATE_SETTLING) {
344                             mPerformanceMonitor.startJankRecorder(
345                                     EventNames.PROGRAM_GUIDE_SCROLL_VERTICALLY);
346                         } else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
347                             mPerformanceMonitor.stopJankRecorder(
348                                     EventNames.PROGRAM_GUIDE_SCROLL_VERTICALLY);
349                         }
350                     }
351                 });
352 
353         RecyclerView.OnScrollListener onScrollListener =
354                 new RecyclerView.OnScrollListener() {
355                     @Override
356                     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
357                         onHorizontalScrolled(dx);
358                     }
359 
360                     @Override
361                     public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
362                         if (DEBUG) {
363                             Log.d(TAG, "TimelineRow onScrollStateChanged. newState=" + newState);
364                         }
365                         if (newState == RecyclerView.SCROLL_STATE_SETTLING) {
366                             mPerformanceMonitor.startJankRecorder(
367                                     EventNames.PROGRAM_GUIDE_SCROLL_HORIZONTALLY);
368                         } else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
369                             mPerformanceMonitor.stopJankRecorder(
370                                     EventNames.PROGRAM_GUIDE_SCROLL_HORIZONTALLY);
371                         }
372                     }
373                 };
374         mTimelineRow.addOnScrollListener(onScrollListener);
375 
376         mCurrentTimeIndicator = mTable.findViewById(R.id.current_time_indicator);
377 
378         mShowAnimatorFull =
379                 createAnimator(
380                         R.animator.program_guide_side_panel_enter_full,
381                         0,
382                         R.animator.program_guide_table_enter_full);
383         mShowAnimatorFull.addListener(
384                 new AnimatorListenerAdapter() {
385                     @Override
386                     public void onAnimationEnd(Animator animation) {
387                         if (mTimerEvent != null) {
388                             mPerformanceMonitor.stopTimer(
389                                     mTimerEvent, EventNames.PROGRAM_GUIDE_SHOW);
390                             mTimerEvent = null;
391                         }
392                         mPerformanceMonitor.stopJankRecorder(EventNames.PROGRAM_GUIDE_SHOW);
393                     }
394                 });
395 
396         mShowAnimatorPartial =
397                 createAnimator(
398                         R.animator.program_guide_side_panel_enter_partial,
399                         0,
400                         R.animator.program_guide_table_enter_partial);
401         mShowAnimatorPartial.addListener(
402                 new AnimatorListenerAdapter() {
403                     @Override
404                     public void onAnimationStart(Animator animation) {
405                         mSidePanelGridView.setVisibility(View.VISIBLE);
406                         mSidePanelGridView.setAlpha(1.0f);
407                     }
408 
409                     @Override
410                     public void onAnimationEnd(Animator animation) {
411                         if (mTimerEvent != null) {
412                             mPerformanceMonitor.stopTimer(
413                                     mTimerEvent, EventNames.PROGRAM_GUIDE_SHOW);
414                             mTimerEvent = null;
415                         }
416                         mPerformanceMonitor.stopJankRecorder(EventNames.PROGRAM_GUIDE_SHOW);
417                     }
418                 });
419 
420         mHideAnimatorFull =
421                 createAnimator(
422                         R.animator.program_guide_side_panel_exit,
423                         0,
424                         R.animator.program_guide_table_exit);
425         mHideAnimatorFull.addListener(
426                 new AnimatorListenerAdapter() {
427                     @Override
428                     public void onAnimationStart(Animator animation) {
429                         mPerformanceMonitor.recordMemory(EventNames.MEMORY_ON_PROGRAM_GUIDE_CLOSE);
430                     }
431 
432                     @Override
433                     public void onAnimationEnd(Animator animation) {
434                         mContainer.setVisibility(View.GONE);
435                     }
436                 });
437         mHideAnimatorPartial =
438                 createAnimator(
439                         R.animator.program_guide_side_panel_exit,
440                         0,
441                         R.animator.program_guide_table_exit);
442         mHideAnimatorPartial.addListener(
443                 new AnimatorListenerAdapter() {
444                     @Override
445                     public void onAnimationStart(Animator animation) {
446                         mPerformanceMonitor.recordMemory(EventNames.MEMORY_ON_PROGRAM_GUIDE_CLOSE);
447                     }
448 
449                     @Override
450                     public void onAnimationEnd(Animator animation) {
451                         mContainer.setVisibility(View.GONE);
452                     }
453                 });
454 
455         mPartialToFullAnimator =
456                 createAnimator(
457                         R.animator.program_guide_side_panel_hide,
458                         R.animator.program_guide_side_panel_grid_fade_out,
459                         R.animator.program_guide_table_partial_to_full);
460         mFullToPartialAnimator =
461                 createAnimator(
462                         R.animator.program_guide_side_panel_reveal,
463                         R.animator.program_guide_side_panel_grid_fade_in,
464                         R.animator.program_guide_table_full_to_partial);
465 
466         mProgramTableFadeOutAnimator =
467                 AnimatorInflater.loadAnimator(mActivity, R.animator.program_guide_table_fade_out);
468         mProgramTableFadeOutAnimator.setTarget(mTable);
469         mProgramTableFadeOutAnimator.addListener(
470                 new HardwareLayerAnimatorListenerAdapter(mTable) {
471                     @Override
472                     public void onAnimationEnd(Animator animation) {
473                         super.onAnimationEnd(animation);
474 
475                         if (!isActive()) {
476                             return;
477                         }
478                         mProgramManager.resetChannelListWithGenre(mLastRequestedGenreId);
479                         resetTimelineScroll();
480                         if (!mHandler.hasMessages(MSG_PROGRAM_TABLE_FADE_IN_ANIM)) {
481                             mHandler.sendEmptyMessage(MSG_PROGRAM_TABLE_FADE_IN_ANIM);
482                         }
483                     }
484                 });
485         mProgramTableFadeInAnimator =
486                 AnimatorInflater.loadAnimator(mActivity, R.animator.program_guide_table_fade_in);
487         mProgramTableFadeInAnimator.setTarget(mTable);
488         mProgramTableFadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable));
489         mSharedPreference = PreferenceManager.getDefaultSharedPreferences(mActivity);
490         mAccessibilityManager =
491                 (AccessibilityManager) mActivity.getSystemService(Context.ACCESSIBILITY_SERVICE);
492         mShowGuidePartial =
493                 mAccessibilityManager.isEnabled()
494                         || mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true);
495         mAutoHideScheduler = new AutoHideScheduler(activity, this::hide);
496     }
497 
498     @Override
onRequestChildFocus(View oldFocus, View newFocus)499     public void onRequestChildFocus(View oldFocus, View newFocus) {
500         if (oldFocus != null && newFocus != null) {
501             int selectionRowOffset = mSelectionRow * mRowHeight;
502             if (oldFocus.getTop() < newFocus.getTop()) {
503                 // Selection moves downwards
504                 // Adjust scroll offset to be at the bottom of the target row and to expand up. This
505                 // will set the scroll target to be one row height up from its current position.
506                 mGrid.setWindowAlignmentOffset(selectionRowOffset + mRowHeight + mDetailHeight);
507                 mGrid.setItemAlignmentOffsetPercent(100);
508             } else if (oldFocus.getTop() > newFocus.getTop()) {
509                 // Selection moves upwards
510                 // Adjust scroll offset to be at the top of the target row and to expand down. This
511                 // will set the scroll target to be one row height down from its current position.
512                 mGrid.setWindowAlignmentOffset(selectionRowOffset);
513                 mGrid.setItemAlignmentOffsetPercent(0);
514             }
515         }
516     }
517 
518     /**
519      * Show the program guide. This reveals the side panel, and the program guide table is shown
520      * partially.
521      *
522      * <p>Note: the animation which starts together with ProgramGuide showing animation needs to be
523      * initiated in {@code runnableAfterAnimatorReady}. If the animation starts together with
524      * show(), the animation may drop some frames.
525      */
show(final Runnable runnableAfterAnimatorReady)526     public void show(final Runnable runnableAfterAnimatorReady) {
527         if (mContainer.getVisibility() == View.VISIBLE) {
528             return;
529         }
530         mTimerEvent = mPerformanceMonitor.startTimer();
531         mPerformanceMonitor.startJankRecorder(EventNames.PROGRAM_GUIDE_SHOW);
532         mTracker.sendShowEpg();
533         mTracker.sendScreenView(SCREEN_NAME);
534         if (mPreShowRunnable != null) {
535             mPreShowRunnable.run();
536         }
537         mVisibleDuration.start();
538 
539         mProgramManager.programGuideVisibilityChanged(true);
540         mStartUtcTime =
541                 Utils.floorTime(
542                         System.currentTimeMillis() - MIN_DURATION_FROM_START_TIME_TO_CURRENT_TIME,
543                         HALF_HOUR_IN_MILLIS);
544         mProgramManager.updateInitialTimeRange(mStartUtcTime, mStartUtcTime + mViewPortMillis);
545         mProgramManager.addListener(mProgramManagerListener);
546         mLastRequestedGenreId = GenreItems.ID_ALL_CHANNELS;
547         mTimeListAdapter.update(mStartUtcTime);
548         mTimelineRow.resetScroll();
549 
550         mContainer.setVisibility(View.VISIBLE);
551         mActive = true;
552         if (!mShowGuidePartial) {
553             mTable.requestFocus();
554         }
555         positionCurrentTimeIndicator();
556         mSidePanelGridView.setSelectedPosition(0);
557         if (DEBUG) {
558             Log.d(TAG, "show()");
559         }
560         mOnLayoutListenerForShow =
561                 new ViewTreeObserver.OnGlobalLayoutListener() {
562                     @Override
563                     public void onGlobalLayout() {
564                         mContainer.getViewTreeObserver().removeOnGlobalLayoutListener(this);
565                         mTable.setLayerType(View.LAYER_TYPE_HARDWARE, null);
566                         mSidePanelGridView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
567                         mTable.buildLayer();
568                         mSidePanelGridView.buildLayer();
569                         mOnLayoutListenerForShow = null;
570                         mTimelineAnimation = true;
571                         // Make sure that time indicator update starts after animation is finished.
572                         startCurrentTimeIndicator(TIME_INDICATOR_UPDATE_FREQUENCY);
573                         if (DEBUG) {
574                             mContainer
575                                     .getViewTreeObserver()
576                                     .addOnDrawListener(
577                                             new ViewTreeObserver.OnDrawListener() {
578                                                 long time = System.currentTimeMillis();
579                                                 int count = 0;
580 
581                                                 @Override
582                                                 public void onDraw() {
583                                                     long curtime = System.currentTimeMillis();
584                                                     Log.d(
585                                                             TAG,
586                                                             "onDraw "
587                                                                     + count++
588                                                                     + " "
589                                                                     + (curtime - time)
590                                                                     + "ms");
591                                                     time = curtime;
592                                                     if (count > 10) {
593                                                         mContainer
594                                                                 .getViewTreeObserver()
595                                                                 .removeOnDrawListener(this);
596                                                     }
597                                                 }
598                                             });
599                         }
600                         updateGuidePosition();
601                         runnableAfterAnimatorReady.run();
602                         if (mShowGuidePartial) {
603                             mShowAnimatorPartial.start();
604                         } else {
605                             mShowAnimatorFull.start();
606                         }
607                     }
608                 };
609         mContainer.getViewTreeObserver().addOnGlobalLayoutListener(mOnLayoutListenerForShow);
610         scheduleHide();
611     }
612 
613     /** Hide the program guide. */
hide()614     public void hide() {
615         if (!isActive()) {
616             return;
617         }
618         if (mOnLayoutListenerForShow != null) {
619             mContainer.getViewTreeObserver().removeOnGlobalLayoutListener(mOnLayoutListenerForShow);
620             mOnLayoutListenerForShow = null;
621         }
622         mTracker.sendHideEpg(mVisibleDuration.reset());
623         cancelHide();
624         mProgramManager.programGuideVisibilityChanged(false);
625         mProgramManager.removeListener(mProgramManagerListener);
626         mActive = false;
627         if (!mShowGuidePartial) {
628             mHideAnimatorFull.start();
629         } else {
630             mHideAnimatorPartial.start();
631         }
632 
633         // Clears fade-out/in animation for genre change
634         if (mProgramTableFadeOutAnimator.isRunning()) {
635             mProgramTableFadeOutAnimator.cancel();
636         }
637         if (mProgramTableFadeInAnimator.isRunning()) {
638             mProgramTableFadeInAnimator.cancel();
639         }
640         mHandler.removeMessages(MSG_PROGRAM_TABLE_FADE_IN_ANIM);
641         mTable.setAlpha(1.0f);
642 
643         mTimelineAnimation = false;
644         stopCurrentTimeIndicator();
645         if (mPostHideRunnable != null) {
646             mPostHideRunnable.run();
647         }
648     }
649 
650     /** Schedules hiding the program guide. */
scheduleHide()651     public void scheduleHide() {
652         mAutoHideScheduler.schedule(mShowDurationMillis);
653     }
654 
655     /** Cancels hiding the program guide. */
cancelHide()656     public void cancelHide() {
657         mAutoHideScheduler.cancel();
658     }
659 
660     /** Process the {@code KEYCODE_BACK} key event. */
onBackPressed()661     public void onBackPressed() {
662         hide();
663     }
664 
665     /** Returns {@code true} if the program guide should process the input events. */
isActive()666     public boolean isActive() {
667         return mActive;
668     }
669 
670     /**
671      * Returns {@code true} if the program guide is shown, i.e. showing animation is done and hiding
672      * animation is not started yet.
673      */
isRunningAnimation()674     public boolean isRunningAnimation() {
675         return mShowAnimatorPartial.isStarted()
676                 || mShowAnimatorFull.isStarted()
677                 || mHideAnimatorPartial.isStarted()
678                 || mHideAnimatorFull.isStarted();
679     }
680 
681     /** Returns if program table is in full screen mode. * */
isFull()682     boolean isFull() {
683         return !mShowGuidePartial;
684     }
685 
686     /** Requests change genre to {@code genreId}. */
requestGenreChange(int genreId)687     void requestGenreChange(int genreId) {
688         if (mLastRequestedGenreId == genreId) {
689             // When Recycler.onLayout() removes its children to recycle,
690             // View tries to find next focus candidate immediately
691             // so GenreListAdapter can take focus back while it's hiding.
692             // Returns early here to prevent re-entrance.
693             return;
694         }
695         mLastRequestedGenreId = genreId;
696         if (mProgramTableFadeOutAnimator.isStarted()) {
697             // When requestGenreChange is called repeatedly in short time, we keep the fade-out
698             // state for mTableFadeAnimDuration from now. Without it, we'll see blinks.
699             mHandler.removeMessages(MSG_PROGRAM_TABLE_FADE_IN_ANIM);
700             mHandler.sendEmptyMessageDelayed(
701                     MSG_PROGRAM_TABLE_FADE_IN_ANIM, mTableFadeAnimDuration);
702             return;
703         }
704         if (mHandler.hasMessages(MSG_PROGRAM_TABLE_FADE_IN_ANIM)) {
705             mProgramManager.resetChannelListWithGenre(mLastRequestedGenreId);
706             mHandler.removeMessages(MSG_PROGRAM_TABLE_FADE_IN_ANIM);
707             mHandler.sendEmptyMessageDelayed(
708                     MSG_PROGRAM_TABLE_FADE_IN_ANIM, mTableFadeAnimDuration);
709             return;
710         }
711         if (mProgramTableFadeInAnimator.isStarted()) {
712             mProgramTableFadeInAnimator.cancel();
713         }
714 
715         mProgramTableFadeOutAnimator.start();
716     }
717 
718     /** Returns the scroll offset of the time line row in pixels. */
getTimelineRowScrollOffset()719     int getTimelineRowScrollOffset() {
720         return mTimelineRow.getScrollOffset();
721     }
722 
723     /** Returns the program grid view that hold all component views. */
getProgramGrid()724     ProgramGrid getProgramGrid() {
725         return mGrid;
726     }
727 
728     /** Returns if Accessibility is enabled. */
isAccessibilityEnabled()729     boolean isAccessibilityEnabled() {
730         return mAccessibilityManager.isEnabled();
731     }
732 
733     /** Gets {@link VerticalGridView} for "genre select" side panel. */
getSidePanel()734     VerticalGridView getSidePanel() {
735         return mSidePanelGridView;
736     }
737 
738     /** Returns the program manager the program guide is using to provide program information. */
getProgramManager()739     ProgramManager getProgramManager() {
740         return mProgramManager;
741     }
742 
updateGuidePosition()743     private void updateGuidePosition() {
744         // Align EPG at vertical center, if EPG table height is less than the screen size.
745         Resources res = mActivity.getResources();
746         int screenHeight = mContainer.getHeight();
747         if (screenHeight <= 0) {
748             // mContainer is not initialized yet.
749             return;
750         }
751         int startPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_start);
752         int topPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_top);
753         int bottomPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_bottom);
754         int tableHeight =
755                 res.getDimensionPixelOffset(R.dimen.program_guide_table_header_row_height)
756                         + mDetailHeight
757                         + mRowHeight * mGrid.getAdapter().getItemCount()
758                         + topPadding
759                         + bottomPadding;
760         if (tableHeight > screenHeight) {
761             // EPG height is longer that the screen height.
762             mTable.setPaddingRelative(startPadding, topPadding, 0, 0);
763             LayoutParams layoutParams = mTable.getLayoutParams();
764             layoutParams.height = LayoutParams.WRAP_CONTENT;
765             mTable.setLayoutParams(layoutParams);
766         } else {
767             mTable.setPaddingRelative(startPadding, topPadding, 0, bottomPadding);
768             LayoutParams layoutParams = mTable.getLayoutParams();
769             layoutParams.height = tableHeight;
770             mTable.setLayoutParams(layoutParams);
771         }
772     }
773 
createAnimator( int sidePanelAnimResId, int sidePanelGridAnimResId, int tableAnimResId)774     private Animator createAnimator(
775             int sidePanelAnimResId, int sidePanelGridAnimResId, int tableAnimResId) {
776         List<Animator> animatorList = new ArrayList<>();
777 
778         Animator sidePanelAnimator = AnimatorInflater.loadAnimator(mActivity, sidePanelAnimResId);
779         sidePanelAnimator.setTarget(mSidePanel);
780         animatorList.add(sidePanelAnimator);
781 
782         if (sidePanelGridAnimResId != 0) {
783             Animator sidePanelGridAnimator =
784                     AnimatorInflater.loadAnimator(mActivity, sidePanelGridAnimResId);
785             sidePanelGridAnimator.setTarget(mSidePanelGridView);
786             sidePanelGridAnimator.addListener(
787                     new HardwareLayerAnimatorListenerAdapter(mSidePanelGridView));
788             animatorList.add(sidePanelGridAnimator);
789         }
790         Animator tableAnimator = AnimatorInflater.loadAnimator(mActivity, tableAnimResId);
791         tableAnimator.setTarget(mTable);
792         tableAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable));
793         animatorList.add(tableAnimator);
794 
795         AnimatorSet set = new AnimatorSet();
796         set.playTogether(animatorList);
797         return set;
798     }
799 
startFull()800     private void startFull() {
801         if (!mShowGuidePartial) {
802             return;
803         }
804         mShowGuidePartial = false;
805         mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply();
806         mPartialToFullAnimator.start();
807     }
808 
startPartial()809     private void startPartial() {
810         if (mShowGuidePartial) {
811             return;
812         }
813         mShowGuidePartial = true;
814         mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply();
815         mFullToPartialAnimator.start();
816     }
817 
startCurrentTimeIndicator(long initialDelay)818     private void startCurrentTimeIndicator(long initialDelay) {
819         mHandler.postDelayed(mUpdateTimeIndicator, initialDelay);
820     }
821 
stopCurrentTimeIndicator()822     private void stopCurrentTimeIndicator() {
823         mHandler.removeCallbacks(mUpdateTimeIndicator);
824     }
825 
positionCurrentTimeIndicator()826     private void positionCurrentTimeIndicator() {
827         int offset =
828                 GuideUtils.convertMillisToPixel(mStartUtcTime, System.currentTimeMillis())
829                         - mTimelineRow.getScrollOffset();
830         if (offset < 0) {
831             mCurrentTimeIndicator.setVisibility(View.GONE);
832         } else {
833             if (mCurrentTimeIndicatorWidth == 0) {
834                 mCurrentTimeIndicator.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
835                 mCurrentTimeIndicatorWidth = mCurrentTimeIndicator.getMeasuredWidth();
836             }
837             mCurrentTimeIndicator.setPaddingRelative(
838                     offset - mCurrentTimeIndicatorWidth / 2, 0, 0, 0);
839             mCurrentTimeIndicator.setVisibility(View.VISIBLE);
840         }
841     }
842 
resetTimelineScroll()843     private void resetTimelineScroll() {
844         if (mProgramManager.getFromUtcMillis() != mStartUtcTime) {
845             boolean timelineAnimation = mTimelineAnimation;
846             mTimelineAnimation = false;
847             // mProgramManagerListener.onTimeRangeUpdated() will be called by shiftTime().
848             mProgramManager.shiftTime(mStartUtcTime - mProgramManager.getFromUtcMillis());
849             mTimelineAnimation = timelineAnimation;
850         }
851     }
852 
onHorizontalScrolled(int dx)853     private void onHorizontalScrolled(int dx) {
854         if (DEBUG) Log.d(TAG, "onHorizontalScrolled(dx=" + dx + ")");
855         positionCurrentTimeIndicator();
856         for (int i = 0, n = mGrid.getChildCount(); i < n; ++i) {
857             mGrid.getChildAt(i).findViewById(R.id.row).scrollBy(dx, 0);
858         }
859     }
860 
resetRowSelection()861     private void resetRowSelection() {
862         if (mDetailOutAnimator != null) {
863             mDetailOutAnimator.end();
864         }
865         if (mDetailInAnimator != null) {
866             mDetailInAnimator.cancel();
867         }
868         mSelectedRow = null;
869         mIsDuringResetRowSelection = true;
870         mGrid.setSelectedPosition(
871                 Math.max(mProgramManager.getChannelIndex(mChannelTuner.getCurrentChannel()), 0));
872         mGrid.resetFocusState();
873         mGrid.onItemSelectionReset();
874         mIsDuringResetRowSelection = false;
875     }
876 
selectRow(View row)877     private void selectRow(View row) {
878         if (row == null || row == mSelectedRow) {
879             return;
880         }
881         if (mSelectedRow == null
882                 || mGrid.getChildAdapterPosition(mSelectedRow) == RecyclerView.NO_POSITION) {
883             if (mSelectedRow != null) {
884                 View oldDetailView = mSelectedRow.findViewById(R.id.detail);
885                 oldDetailView.setVisibility(View.GONE);
886             }
887             View detailView = row.findViewById(R.id.detail);
888             detailView.findViewById(R.id.detail_content_full).setAlpha(1);
889             detailView.findViewById(R.id.detail_content_full).setTranslationY(0);
890             ViewUtils.setLayoutHeight(detailView, mDetailHeight);
891             detailView.setVisibility(View.VISIBLE);
892 
893             final ProgramRow programRow = (ProgramRow) row.findViewById(R.id.row);
894             programRow.post(programRow::focusCurrentProgram);
895         } else {
896             animateRowChange(mSelectedRow, row);
897         }
898         mSelectedRow = row;
899     }
900 
animateRowChange(View outRow, View inRow)901     private void animateRowChange(View outRow, View inRow) {
902         if (mDetailOutAnimator != null) {
903             mDetailOutAnimator.end();
904         }
905         if (mDetailInAnimator != null) {
906             mDetailInAnimator.cancel();
907         }
908 
909         int operationDirection = mGrid.getLastUpDownDirection();
910         int animationPadding = 0;
911         if (operationDirection == View.FOCUS_UP) {
912             animationPadding = mDetailPadding;
913         } else if (operationDirection == View.FOCUS_DOWN) {
914             animationPadding = -mDetailPadding;
915         }
916 
917         View outDetail = outRow != null ? outRow.findViewById(R.id.detail) : null;
918         if (outDetail != null && outDetail.isShown()) {
919             final View outDetailContent = outDetail.findViewById(R.id.detail_content_full);
920 
921             Animator fadeOutAnimator =
922                     ObjectAnimator.ofPropertyValuesHolder(
923                             outDetailContent,
924                             PropertyValuesHolder.ofFloat(View.ALPHA, outDetail.getAlpha(), 0f),
925                             PropertyValuesHolder.ofFloat(
926                                     View.TRANSLATION_Y,
927                                     outDetailContent.getTranslationY(),
928                                     animationPadding));
929             fadeOutAnimator.setStartDelay(0);
930             fadeOutAnimator.setDuration(mAnimationDuration);
931             fadeOutAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(outDetailContent));
932 
933             Animator collapseAnimator =
934                     ViewUtils.createHeightAnimator(
935                             outDetail, ViewUtils.getLayoutHeight(outDetail), 0);
936             collapseAnimator.setStartDelay(mAnimationDuration);
937             collapseAnimator.setDuration(mTableFadeAnimDuration);
938             collapseAnimator.addListener(
939                     new AnimatorListenerAdapter() {
940                         @Override
941                         public void onAnimationStart(Animator animator) {
942                             outDetailContent.setVisibility(View.GONE);
943                         }
944 
945                         @Override
946                         public void onAnimationEnd(Animator animator) {
947                             outDetailContent.setVisibility(View.VISIBLE);
948                         }
949                     });
950 
951             AnimatorSet outAnimator = new AnimatorSet();
952             outAnimator.playTogether(fadeOutAnimator, collapseAnimator);
953             outAnimator.addListener(
954                     new AnimatorListenerAdapter() {
955                         @Override
956                         public void onAnimationEnd(Animator animator) {
957                             mDetailOutAnimator = null;
958                         }
959                     });
960             mDetailOutAnimator = outAnimator;
961             outAnimator.start();
962         }
963 
964         View inDetail = inRow != null ? inRow.findViewById(R.id.detail) : null;
965         if (inDetail != null) {
966             final View inDetailContent = inDetail.findViewById(R.id.detail_content_full);
967 
968             Animator expandAnimator = ViewUtils.createHeightAnimator(inDetail, 0, mDetailHeight);
969             expandAnimator.setStartDelay(mAnimationDuration);
970             expandAnimator.setDuration(mTableFadeAnimDuration);
971             expandAnimator.addListener(
972                     new AnimatorListenerAdapter() {
973                         @Override
974                         public void onAnimationStart(Animator animator) {
975                             inDetailContent.setVisibility(View.GONE);
976                         }
977 
978                         @Override
979                         public void onAnimationEnd(Animator animator) {
980                             inDetailContent.setVisibility(View.VISIBLE);
981                             inDetailContent.setAlpha(0);
982                         }
983                     });
984             Animator fadeInAnimator =
985                     ObjectAnimator.ofPropertyValuesHolder(
986                             inDetailContent,
987                             PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f),
988                             PropertyValuesHolder.ofFloat(
989                                     View.TRANSLATION_Y, -animationPadding, 0f));
990             fadeInAnimator.setDuration(mAnimationDuration);
991             fadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(inDetailContent));
992 
993             AnimatorSet inAnimator = new AnimatorSet();
994             inAnimator.playSequentially(expandAnimator, fadeInAnimator);
995             inAnimator.addListener(
996                     new AnimatorListenerAdapter() {
997                         @Override
998                         public void onAnimationEnd(Animator animator) {
999                             mDetailInAnimator = null;
1000                         }
1001                     });
1002             mDetailInAnimator = inAnimator;
1003             inAnimator.start();
1004         }
1005     }
1006 
1007     @Override
onAccessibilityStateChanged(boolean enabled)1008     public void onAccessibilityStateChanged(boolean enabled) {
1009         mAutoHideScheduler.onAccessibilityStateChanged(enabled);
1010     }
1011 
1012     private class GlobalFocusChangeListener
1013             implements ViewTreeObserver.OnGlobalFocusChangeListener {
1014         private static final int UNKNOWN = 0;
1015         private static final int SIDE_PANEL = 1;
1016         private static final int PROGRAM_TABLE = 2;
1017         private static final int CHANNEL_COLUMN = 3;
1018 
1019         @Override
onGlobalFocusChanged(View oldFocus, View newFocus)1020         public void onGlobalFocusChanged(View oldFocus, View newFocus) {
1021             if (DEBUG) Log.d(TAG, "onGlobalFocusChanged " + oldFocus + " -> " + newFocus);
1022             if (!isActive()) {
1023                 return;
1024             }
1025             int fromLocation = getLocation(oldFocus);
1026             int toLocation = getLocation(newFocus);
1027             if (fromLocation == SIDE_PANEL && toLocation == PROGRAM_TABLE) {
1028                 startFull();
1029             } else if (fromLocation == PROGRAM_TABLE && toLocation == SIDE_PANEL) {
1030                 startPartial();
1031             } else if (fromLocation == CHANNEL_COLUMN && toLocation == PROGRAM_TABLE) {
1032                 startFull();
1033             } else if (fromLocation == PROGRAM_TABLE && toLocation == CHANNEL_COLUMN) {
1034                 startPartial();
1035             }
1036         }
1037 
getLocation(View view)1038         private int getLocation(View view) {
1039             if (view == null) {
1040                 return UNKNOWN;
1041             }
1042             for (Object obj = view; obj instanceof View; obj = ((View) obj).getParent()) {
1043                 if (obj == mSidePanel) {
1044                     return SIDE_PANEL;
1045                 } else if (obj == mGrid) {
1046                     if (view instanceof ProgramItemView) {
1047                         return PROGRAM_TABLE;
1048                     } else {
1049                         return CHANNEL_COLUMN;
1050                     }
1051                 }
1052             }
1053             return UNKNOWN;
1054         }
1055     }
1056 
1057     private class ProgramManagerListener extends ProgramManager.ListenerAdapter {
1058         @Override
onTimeRangeUpdated()1059         public void onTimeRangeUpdated() {
1060             int scrollOffset =
1061                     (int) (mWidthPerHour * mProgramManager.getShiftedTime() / HOUR_IN_MILLIS);
1062             if (DEBUG) {
1063                 Log.d(
1064                         TAG,
1065                         "Horizontal scroll to "
1066                                 + scrollOffset
1067                                 + " pixels ("
1068                                 + mProgramManager.getShiftedTime()
1069                                 + " millis)");
1070             }
1071             mTimelineRow.scrollTo(scrollOffset, mTimelineAnimation);
1072         }
1073     }
1074 
1075     private static class ProgramGuideHandler extends WeakHandler<ProgramGuide> {
ProgramGuideHandler(ProgramGuide ref)1076         ProgramGuideHandler(ProgramGuide ref) {
1077             super(ref);
1078         }
1079 
1080         @Override
handleMessage(Message msg, @NonNull ProgramGuide programGuide)1081         public void handleMessage(Message msg, @NonNull ProgramGuide programGuide) {
1082             if (msg.what == MSG_PROGRAM_TABLE_FADE_IN_ANIM) {
1083                 programGuide.mProgramTableFadeInAnimator.start();
1084             }
1085         }
1086     }
1087 }
1088