1 /*
2  * Copyright (C) 2016 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.dvr.ui.list;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.annotation.TargetApi;
22 import android.app.Activity;
23 import android.content.Context;
24 import android.content.res.Resources;
25 import android.os.Build;
26 import android.support.annotation.IntDef;
27 import androidx.leanback.widget.RowPresenter;
28 import android.text.TextUtils;
29 import android.view.LayoutInflater;
30 import android.view.View;
31 import android.view.View.OnFocusChangeListener;
32 import android.view.ViewGroup;
33 import android.view.animation.DecelerateInterpolator;
34 import android.widget.ImageView;
35 import android.widget.LinearLayout;
36 import android.widget.RelativeLayout;
37 import android.widget.TextView;
38 import android.widget.Toast;
39 import com.android.tv.R;
40 import com.android.tv.TvSingletons;
41 import com.android.tv.common.SoftPreconditions;
42 import com.android.tv.data.api.Channel;
43 import com.android.tv.dialog.HalfSizedDialogFragment;
44 import com.android.tv.dvr.DvrManager;
45 import com.android.tv.dvr.DvrScheduleManager;
46 import com.android.tv.dvr.data.ScheduledRecording;
47 import com.android.tv.dvr.ui.DvrStopRecordingFragment;
48 import com.android.tv.dvr.ui.DvrUiHelper;
49 import com.android.tv.util.ToastUtils;
50 import com.android.tv.util.Utils;
51 import java.lang.annotation.Retention;
52 import java.lang.annotation.RetentionPolicy;
53 import java.util.List;
54 
55 /** A RowPresenter for {@link ScheduleRow}. */
56 @TargetApi(Build.VERSION_CODES.N)
57 class ScheduleRowPresenter extends RowPresenter {
58     private static final String TAG = "ScheduleRowPresenter";
59 
60     @Retention(RetentionPolicy.SOURCE)
61     @IntDef({
62         ACTION_START_RECORDING,
63         ACTION_STOP_RECORDING,
64         ACTION_CREATE_SCHEDULE,
65         ACTION_REMOVE_SCHEDULE
66     })
67     public @interface ScheduleRowAction {}
68     /** An action to start recording. */
69     public static final int ACTION_START_RECORDING = 1;
70     /** An action to stop recording. */
71     public static final int ACTION_STOP_RECORDING = 2;
72     /** An action to create schedule for the row. */
73     public static final int ACTION_CREATE_SCHEDULE = 3;
74     /** An action to remove the schedule. */
75     public static final int ACTION_REMOVE_SCHEDULE = 4;
76 
77     private final Context mContext;
78     private final DvrManager mDvrManager;
79     private final DvrScheduleManager mDvrScheduleManager;
80 
81     private final String mTunerConflictWillNotBeRecordedInfo;
82     private final String mTunerConflictWillBePartiallyRecordedInfo;
83     private final int mAnimationDuration;
84 
85     private int mLastFocusedViewId;
86 
87     /** A ViewHolder for {@link ScheduleRow} */
88     public static class ScheduleRowViewHolder extends RowPresenter.ViewHolder {
89         private ScheduleRowPresenter mPresenter;
90         @ScheduleRowAction private int[] mActions;
91         private boolean mLtr;
92         private final LinearLayout mInfoContainer;
93         // The first action is on the right of the second action.
94         private final RelativeLayout mSecondActionContainer;
95         private final RelativeLayout mFirstActionContainer;
96         private final View mSelectorView;
97         private final TextView mTimeView;
98         private final TextView mProgramTitleView;
99         private final TextView mInfoSeparatorView;
100         private final TextView mChannelNameView;
101         private final ImageView mExtraInfoIcon;
102         private final TextView mExtraInfoView;
103         private final ImageView mSecondActionView;
104         private final ImageView mFirstActionView;
105 
106         private Runnable mPendingAnimationRunnable;
107 
108         private final int mSelectorTranslationDelta;
109         private final int mSelectorWidthDelta;
110         private final int mInfoContainerTargetWidthWithNoAction;
111         private final int mInfoContainerTargetWidthWithOneAction;
112         private final int mInfoContainerTargetWidthWithTwoAction;
113         private final int mRoundRectRadius;
114 
115         private final OnFocusChangeListener mOnFocusChangeListener =
116                 new View.OnFocusChangeListener() {
117                     @Override
118                     public void onFocusChange(View view, boolean focused) {
119                         view.post(
120                                 () -> {
121                                     if (view.isFocused()) {
122                                         mPresenter.mLastFocusedViewId = view.getId();
123                                     }
124                                     updateSelector();
125                                 });
126                     }
127                 };
128 
ScheduleRowViewHolder(View view, ScheduleRowPresenter presenter)129         public ScheduleRowViewHolder(View view, ScheduleRowPresenter presenter) {
130             super(view);
131             mPresenter = presenter;
132             mLtr =
133                     view.getContext().getResources().getConfiguration().getLayoutDirection()
134                             == View.LAYOUT_DIRECTION_LTR;
135             mInfoContainer = (LinearLayout) view.findViewById(R.id.info_container);
136             mSecondActionContainer =
137                     (RelativeLayout) view.findViewById(R.id.action_second_container);
138             mSecondActionView = (ImageView) view.findViewById(R.id.action_second);
139             mFirstActionContainer = (RelativeLayout) view.findViewById(R.id.action_first_container);
140             mFirstActionView = (ImageView) view.findViewById(R.id.action_first);
141             mSelectorView = view.findViewById(R.id.selector);
142             mTimeView = (TextView) view.findViewById(R.id.time);
143             mProgramTitleView = (TextView) view.findViewById(R.id.program_title);
144             mInfoSeparatorView = (TextView) view.findViewById(R.id.info_separator);
145             mChannelNameView = (TextView) view.findViewById(R.id.channel_name);
146             mExtraInfoIcon = (ImageView) view.findViewById(R.id.extra_info_icon);
147             mExtraInfoView = (TextView) view.findViewById(R.id.extra_info);
148             Resources res = view.getResources();
149             mSelectorTranslationDelta =
150                     res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
151                             - res.getDimensionPixelSize(
152                                     R.dimen.dvr_schedules_item_focus_translation_delta);
153             mSelectorWidthDelta =
154                     res.getDimensionPixelSize(R.dimen.dvr_schedules_item_focus_width_delta);
155             mRoundRectRadius = res.getDimensionPixelSize(R.dimen.dvr_schedules_selector_radius);
156             int fullWidth =
157                     res.getDimensionPixelSize(R.dimen.dvr_schedules_item_width)
158                             - 2 * res.getDimensionPixelSize(R.dimen.dvr_schedules_layout_padding);
159             mInfoContainerTargetWidthWithNoAction = fullWidth + 2 * mRoundRectRadius;
160             mInfoContainerTargetWidthWithOneAction =
161                     fullWidth
162                             - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
163                             - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_delete_width)
164                             + mRoundRectRadius
165                             + mSelectorWidthDelta;
166             mInfoContainerTargetWidthWithTwoAction =
167                     mInfoContainerTargetWidthWithOneAction
168                             - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
169                             - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_icon_size);
170 
171             mInfoContainer.setOnFocusChangeListener(mOnFocusChangeListener);
172             mFirstActionContainer.setOnFocusChangeListener(mOnFocusChangeListener);
173             mSecondActionContainer.setOnFocusChangeListener(mOnFocusChangeListener);
174         }
175 
176         /** Returns time view. */
getTimeView()177         public TextView getTimeView() {
178             return mTimeView;
179         }
180 
181         /** Returns title view. */
getProgramTitleView()182         public TextView getProgramTitleView() {
183             return mProgramTitleView;
184         }
185 
updateSelector()186         private void updateSelector() {
187             int animationDuration =
188                     mSelectorView.getResources().getInteger(android.R.integer.config_shortAnimTime);
189             DecelerateInterpolator interpolator = new DecelerateInterpolator();
190 
191             if (mInfoContainer.isFocused()
192                     || mSecondActionContainer.isFocused()
193                     || mFirstActionContainer.isFocused()) {
194                 final ViewGroup.LayoutParams lp = mSelectorView.getLayoutParams();
195                 final int targetWidth;
196                 if (mInfoContainer.isFocused()) {
197                     // Use actions to check the visibility of the actions instead of calling
198                     // View.getVisibility() because the view could be on the hiding animation.
199                     if (mActions == null || mActions.length == 0) {
200                         targetWidth = mInfoContainerTargetWidthWithNoAction;
201                     } else if (mActions.length == 1) {
202                         targetWidth = mInfoContainerTargetWidthWithOneAction;
203                     } else {
204                         targetWidth = mInfoContainerTargetWidthWithTwoAction;
205                     }
206                 } else if (mSecondActionContainer.isFocused()) {
207                     targetWidth = Math.max(mSecondActionContainer.getWidth(), 2 * mRoundRectRadius);
208                 } else {
209                     targetWidth =
210                             mFirstActionContainer.getWidth()
211                                     + mRoundRectRadius
212                                     + mSelectorTranslationDelta;
213                 }
214 
215                 float targetTranslationX;
216                 if (mInfoContainer.isFocused()) {
217                     targetTranslationX =
218                             mLtr
219                                     ? mInfoContainer.getLeft()
220                                             - mRoundRectRadius
221                                             - mSelectorView.getLeft()
222                                     : mInfoContainer.getRight()
223                                             + mRoundRectRadius
224                                             - mSelectorView.getRight();
225                 } else if (mSecondActionContainer.isFocused()) {
226                     if (mSecondActionContainer.getWidth() > 2 * mRoundRectRadius) {
227                         targetTranslationX =
228                                 mLtr
229                                         ? mSecondActionContainer.getLeft() - mSelectorView.getLeft()
230                                         : mSecondActionContainer.getRight()
231                                                 - mSelectorView.getRight();
232                     } else {
233                         targetTranslationX =
234                                 mLtr
235                                         ? mSecondActionContainer.getLeft()
236                                                 - (mRoundRectRadius
237                                                         - mSecondActionContainer.getWidth() / 2)
238                                                 - mSelectorView.getLeft()
239                                         : mSecondActionContainer.getRight()
240                                                 + (mRoundRectRadius
241                                                         - mSecondActionContainer.getWidth() / 2)
242                                                 - mSelectorView.getRight();
243                     }
244                 } else {
245                     targetTranslationX =
246                             mLtr
247                                     ? mFirstActionContainer.getLeft()
248                                             - mSelectorTranslationDelta
249                                             - mSelectorView.getLeft()
250                                     : mFirstActionContainer.getRight()
251                                             + mSelectorTranslationDelta
252                                             - mSelectorView.getRight();
253                 }
254 
255                 if (mSelectorView.getAlpha() == 0) {
256                     mSelectorView.setTranslationX(targetTranslationX);
257                     lp.width = targetWidth;
258                     mSelectorView.requestLayout();
259                 }
260 
261                 // animate the selector in and to the proper width and translation X.
262                 final float deltaWidth = lp.width - targetWidth;
263                 mSelectorView.animate().cancel();
264                 mSelectorView
265                         .animate()
266                         .translationX(targetTranslationX)
267                         .alpha(1f)
268                         .setUpdateListener(
269                                 animation -> {
270                                     // Set width to the proper width for this animation step.
271                                     float fraction = 1f - animation.getAnimatedFraction();
272                                     lp.width = targetWidth + Math.round(deltaWidth * fraction);
273                                     mSelectorView.requestLayout();
274                                 })
275                         .setDuration(animationDuration)
276                         .setInterpolator(interpolator)
277                         .start();
278                 if (mPendingAnimationRunnable != null) {
279                     mPendingAnimationRunnable.run();
280                     mPendingAnimationRunnable = null;
281                 }
282             } else {
283                 mSelectorView.animate().cancel();
284                 mSelectorView
285                         .animate()
286                         .alpha(0f)
287                         .setDuration(animationDuration)
288                         .setInterpolator(interpolator)
289                         .setUpdateListener(null)
290                         .start();
291             }
292         }
293 
294         /** Grey out the information body. */
greyOutInfo()295         public void greyOutInfo() {
296             mTimeView.setTextColor(
297                     mInfoContainer
298                             .getResources()
299                             .getColor(R.color.dvr_schedules_item_info_grey, null));
300             mProgramTitleView.setTextColor(
301                     mInfoContainer
302                             .getResources()
303                             .getColor(R.color.dvr_schedules_item_info_grey, null));
304             mInfoSeparatorView.setTextColor(
305                     mInfoContainer
306                             .getResources()
307                             .getColor(R.color.dvr_schedules_item_info_grey, null));
308             mChannelNameView.setTextColor(
309                     mInfoContainer
310                             .getResources()
311                             .getColor(R.color.dvr_schedules_item_info_grey, null));
312             mExtraInfoView.setTextColor(
313                     mInfoContainer
314                             .getResources()
315                             .getColor(R.color.dvr_schedules_item_info_grey, null));
316         }
317 
318         /** Reverse grey out operation. */
whiteBackInfo()319         public void whiteBackInfo() {
320             mTimeView.setTextColor(
321                     mInfoContainer.getResources().getColor(R.color.dvr_schedules_item_info, null));
322             mProgramTitleView.setTextColor(
323                     mInfoContainer.getResources().getColor(R.color.dvr_schedules_item_main, null));
324             mInfoSeparatorView.setTextColor(
325                     mInfoContainer.getResources().getColor(R.color.dvr_schedules_item_info, null));
326             mChannelNameView.setTextColor(
327                     mInfoContainer.getResources().getColor(R.color.dvr_schedules_item_info, null));
328             mExtraInfoView.setTextColor(
329                     mInfoContainer.getResources().getColor(R.color.dvr_schedules_item_info, null));
330         }
331     }
332 
ScheduleRowPresenter(Context context)333     public ScheduleRowPresenter(Context context) {
334         setHeaderPresenter(null);
335         setSelectEffectEnabled(false);
336         mContext = context;
337         mDvrManager = TvSingletons.getSingletons(context).getDvrManager();
338         mDvrScheduleManager = TvSingletons.getSingletons(context).getDvrScheduleManager();
339         mTunerConflictWillNotBeRecordedInfo =
340                 mContext.getString(R.string.dvr_schedules_tuner_conflict_will_not_be_recorded_info);
341         mTunerConflictWillBePartiallyRecordedInfo =
342                 mContext.getString(
343                         R.string.dvr_schedules_tuner_conflict_will_be_partially_recorded);
344         mAnimationDuration =
345                 mContext.getResources().getInteger(android.R.integer.config_shortAnimTime);
346     }
347 
348     @Override
createRowViewHolder(ViewGroup parent)349     public ViewHolder createRowViewHolder(ViewGroup parent) {
350         return onGetScheduleRowViewHolder(
351                 LayoutInflater.from(mContext).inflate(R.layout.dvr_schedules_item, parent, false));
352     }
353 
354     /** Returns context. */
getContext()355     protected Context getContext() {
356         return mContext;
357     }
358 
359     /** Returns DVR manager. */
getDvrManager()360     protected DvrManager getDvrManager() {
361         return mDvrManager;
362     }
363 
364     @Override
onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item)365     protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) {
366         super.onBindRowViewHolder(vh, item);
367         ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh;
368         ScheduleRow row = (ScheduleRow) item;
369         @ScheduleRowAction int[] actions = getAvailableActions(row);
370         viewHolder.mActions = actions;
371         viewHolder.mInfoContainer.setOnClickListener(
372                 new View.OnClickListener() {
373                     @Override
374                     public void onClick(View view) {
375                         if (isInfoClickable(row)) {
376                             onInfoClicked(row);
377                         }
378                     }
379                 });
380 
381         viewHolder.mFirstActionContainer.setOnClickListener(
382                 new View.OnClickListener() {
383                     @Override
384                     public void onClick(View view) {
385                         onActionClicked(actions[0], row);
386                     }
387                 });
388 
389         viewHolder.mSecondActionContainer.setOnClickListener(
390                 new View.OnClickListener() {
391                     @Override
392                     public void onClick(View view) {
393                         onActionClicked(actions[1], row);
394                     }
395                 });
396 
397         viewHolder.mTimeView.setText(onGetRecordingTimeText(row));
398         String programInfoText = onGetProgramInfoText(row);
399         if (TextUtils.isEmpty(programInfoText)) {
400             int durationMins = Math.max(1, Utils.getRoundOffMinsFromMs(row.getDuration()));
401             programInfoText =
402                     mContext.getResources()
403                             .getQuantityString(
404                                     R.plurals.dvr_schedules_recording_duration,
405                                     durationMins,
406                                     durationMins);
407         }
408         String channelName = getChannelNameText(row);
409         viewHolder.mProgramTitleView.setText(programInfoText);
410         viewHolder.mInfoSeparatorView.setVisibility(
411                 (!TextUtils.isEmpty(programInfoText) && !TextUtils.isEmpty(channelName))
412                         ? View.VISIBLE
413                         : View.GONE);
414         viewHolder.mChannelNameView.setText(channelName);
415         if (actions != null) {
416             switch (actions.length) {
417                 case 2:
418                     viewHolder.mSecondActionView.setImageResource(getImageForAction(actions[1]));
419                     // fall through
420                 case 1:
421                     viewHolder.mFirstActionView.setImageResource(getImageForAction(actions[0]));
422                     break;
423                 default: // fall out
424             }
425         }
426         ScheduledRecording schedule = row.getSchedule();
427         viewHolder.mExtraInfoIcon.setVisibility(View.GONE);
428         if (mDvrManager.isConflicting(schedule) || isFailedRecording(schedule)) {
429             String extraInfo;
430             if (isFailedRecording(schedule)) {
431                 extraInfo =
432                         mContext.getString(R.string.dvr_recording_failed_short)
433                                 + " "
434                                 + getErrorMessage(schedule);
435                 viewHolder.mExtraInfoIcon.setVisibility(View.VISIBLE);
436             } else if (mDvrScheduleManager.isPartiallyConflicting(row.getSchedule())) {
437                 extraInfo = mTunerConflictWillBePartiallyRecordedInfo;
438             } else {
439                 extraInfo = mTunerConflictWillNotBeRecordedInfo;
440             }
441             viewHolder.mExtraInfoView.setText(extraInfo);
442             viewHolder.mExtraInfoView.setVisibility(View.VISIBLE);
443         } else {
444             viewHolder.mExtraInfoView.setVisibility(View.GONE);
445         }
446         if (shouldBeGrayedOut(row)) {
447             viewHolder.greyOutInfo();
448         } else {
449             viewHolder.whiteBackInfo();
450         }
451         if (isFailedRecording(schedule)) {
452             viewHolder.mExtraInfoView.setTextColor(
453                     viewHolder
454                             .mInfoContainer
455                             .getResources()
456                             .getColor(R.color.dvr_recording_failed_text_color, null));
457         }
458         viewHolder.mInfoContainer.setFocusable(isInfoClickable(row));
459         updateActionContainer(viewHolder, viewHolder.isSelected());
460     }
461 
isFailedRecording(ScheduledRecording scheduledRecording)462     private boolean isFailedRecording(ScheduledRecording scheduledRecording) {
463         return scheduledRecording != null
464                 && scheduledRecording.getState() == ScheduledRecording.STATE_RECORDING_FAILED;
465     }
466 
getErrorMessage(ScheduledRecording recording)467     private String getErrorMessage(ScheduledRecording recording) {
468         int reason =
469                 recording.getFailedReason() == null
470                         ? ScheduledRecording.FAILED_REASON_OTHER
471                         : recording.getFailedReason();
472         switch (reason) {
473             case ScheduledRecording.FAILED_REASON_PROGRAM_ENDED_BEFORE_RECORDING_STARTED:
474                 return mContext.getString(R.string.dvr_recording_failed_not_started_short);
475             case ScheduledRecording.FAILED_REASON_RESOURCE_BUSY:
476                 return mContext.getString(R.string.dvr_recording_failed_resource_busy_short);
477             case ScheduledRecording.FAILED_REASON_INPUT_UNAVAILABLE:
478                 return mContext.getString(
479                         R.string.dvr_recording_failed_input_unavailable_short,
480                         recording.getInputId());
481             case ScheduledRecording.FAILED_REASON_INPUT_DVR_UNSUPPORTED:
482                 return mContext.getString(
483                         R.string.dvr_recording_failed_input_dvr_unsupported_short);
484             case ScheduledRecording.FAILED_REASON_INSUFFICIENT_SPACE:
485                 return mContext.getString(R.string.dvr_recording_failed_insufficient_space_short);
486             case ScheduledRecording.FAILED_REASON_OTHER: // fall through
487             case ScheduledRecording.FAILED_REASON_NOT_FINISHED: // fall through
488             case ScheduledRecording.FAILED_REASON_SCHEDULER_STOPPED: // fall through
489             case ScheduledRecording.FAILED_REASON_INVALID_CHANNEL: // fall through
490             case ScheduledRecording.FAILED_REASON_MESSAGE_NOT_SENT: // fall through
491             case ScheduledRecording.FAILED_REASON_CONNECTION_FAILED: // fall through
492             default:
493                 return mContext.getString(R.string.dvr_recording_failed_system_failure, reason);
494         }
495     }
496 
getImageForAction(@cheduleRowAction int action)497     private int getImageForAction(@ScheduleRowAction int action) {
498         switch (action) {
499             case ACTION_START_RECORDING:
500                 return R.drawable.ic_record_start;
501             case ACTION_STOP_RECORDING:
502                 return R.drawable.ic_record_stop;
503             case ACTION_CREATE_SCHEDULE:
504                 return R.drawable.ic_scheduled_recording;
505             case ACTION_REMOVE_SCHEDULE:
506                 return R.drawable.ic_dvr_cancel;
507             default:
508                 return 0;
509         }
510     }
511 
512     /** Returns view holder for schedule row. */
onGetScheduleRowViewHolder(View view)513     protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) {
514         return new ScheduleRowViewHolder(view, this);
515     }
516 
517     /** Returns time text for time view from scheduled recording. */
onGetRecordingTimeText(ScheduleRow row)518     protected String onGetRecordingTimeText(ScheduleRow row) {
519         return Utils.getDurationString(
520                 mContext, row.getStartTimeMs(), row.getEndTimeMs(), true, false, true, 0);
521     }
522 
523     /** Returns program info text for program title view. */
onGetProgramInfoText(ScheduleRow row)524     protected String onGetProgramInfoText(ScheduleRow row) {
525         return row.getProgramTitleWithEpisodeNumber(mContext);
526     }
527 
getChannelNameText(ScheduleRow row)528     private String getChannelNameText(ScheduleRow row) {
529         Channel channel =
530                 TvSingletons.getSingletons(mContext)
531                         .getChannelDataManager()
532                         .getChannel(row.getChannelId());
533         return channel == null
534                 ? null
535                 : TextUtils.isEmpty(channel.getDisplayName())
536                         ? channel.getDisplayNumber()
537                         : channel.getDisplayName().trim() + " " + channel.getDisplayNumber();
538     }
539 
540     /** Called when user click Info in {@link ScheduleRow}. */
onInfoClicked(ScheduleRow row)541     protected void onInfoClicked(ScheduleRow row) {
542         DvrUiHelper.startDetailsActivity((Activity) mContext, row.getSchedule(), null, true);
543     }
544 
isInfoClickable(ScheduleRow row)545     private boolean isInfoClickable(ScheduleRow row) {
546         ScheduledRecording schedule = row.getSchedule();
547         return schedule != null
548                 && (schedule.isNotStarted()
549                         || schedule.isInProgress()
550                         || schedule.isFinished()
551                         || schedule.isFailed());
552     }
553 
554     /** Called when the button in a row is clicked. */
onActionClicked(@cheduleRowAction final int action, ScheduleRow row)555     protected void onActionClicked(@ScheduleRowAction final int action, ScheduleRow row) {
556         switch (action) {
557             case ACTION_START_RECORDING:
558                 onStartRecording(row);
559                 break;
560             case ACTION_STOP_RECORDING:
561                 onStopRecording(row);
562                 break;
563             case ACTION_CREATE_SCHEDULE:
564                 onCreateSchedule(row);
565                 break;
566             case ACTION_REMOVE_SCHEDULE:
567                 onRemoveSchedule(row);
568                 break;
569             default: // fall out
570         }
571     }
572 
573     /** Action handler for {@link #ACTION_START_RECORDING}. */
onStartRecording(ScheduleRow row)574     protected void onStartRecording(ScheduleRow row) {
575         ScheduledRecording schedule = row.getSchedule();
576         if (schedule == null) {
577             // This row has been deleted.
578             return;
579         }
580         // Checks if there are current recordings that will be stopped by schedule this program.
581         // If so, shows confirmation dialog to users.
582         List<ScheduledRecording> conflictSchedules =
583                 mDvrScheduleManager.getConflictingSchedules(
584                         schedule.getChannelId(),
585                         System.currentTimeMillis(),
586                         schedule.getEndTimeMs());
587         for (int i = conflictSchedules.size() - 1; i >= 0; i--) {
588             ScheduledRecording conflictSchedule = conflictSchedules.get(i);
589             if (conflictSchedule.isInProgress()) {
590                 DvrUiHelper.showStopRecordingDialog(
591                         (Activity) mContext,
592                         conflictSchedule.getChannelId(),
593                         DvrStopRecordingFragment.REASON_ON_CONFLICT,
594                         new HalfSizedDialogFragment.OnActionClickListener() {
595                             @Override
596                             public void onActionClick(long actionId) {
597                                 if (actionId == DvrStopRecordingFragment.ACTION_STOP) {
598                                     onStartRecordingInternal(row);
599                                 }
600                             }
601                         });
602                 return;
603             }
604         }
605         onStartRecordingInternal(row);
606     }
607 
onStartRecordingInternal(ScheduleRow row)608     private void onStartRecordingInternal(ScheduleRow row) {
609         if (row.isOnAir() && !row.isRecordingInProgress() && !row.isStartRecordingRequested()) {
610             row.setStartRecordingRequested(true);
611             if (row.isRecordingNotStarted()) {
612                 mDvrManager.setHighestPriority(row.getSchedule());
613             } else if (row.isRecordingFinished()) {
614                 mDvrManager.addSchedule(
615                         ScheduledRecording.buildFrom(row.getSchedule())
616                                 .setId(ScheduledRecording.ID_NOT_SET)
617                                 .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED)
618                                 .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule()))
619                                 .build());
620             } else {
621                 SoftPreconditions.checkState(
622                         false, TAG, "Invalid row state to start recording: " + row);
623                 return;
624             }
625             String msg =
626                     mContext.getString(
627                             R.string.dvr_msg_current_program_scheduled,
628                             row.getSchedule().getProgramTitle(),
629                             Utils.toTimeString(row.getEndTimeMs(), false));
630             ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT);
631         }
632     }
633 
634     /** Action handler for {@link #ACTION_STOP_RECORDING}. */
onStopRecording(ScheduleRow row)635     protected void onStopRecording(ScheduleRow row) {
636         if (row.getSchedule() == null) {
637             // This row has been deleted.
638             return;
639         }
640         if (row.isRecordingInProgress() && !row.isStopRecordingRequested()) {
641             row.setStopRecordingRequested(true);
642             mDvrManager.stopRecording(row.getSchedule());
643             CharSequence deletedInfo = onGetProgramInfoText(row);
644             if (TextUtils.isEmpty(deletedInfo)) {
645                 deletedInfo = getChannelNameText(row);
646             }
647             ToastUtils.show(
648                     mContext,
649                     mContext.getResources()
650                             .getString(R.string.dvr_schedules_deletion_info, deletedInfo),
651                     Toast.LENGTH_SHORT);
652         }
653     }
654 
655     /** Action handler for {@link #ACTION_CREATE_SCHEDULE}. */
onCreateSchedule(ScheduleRow row)656     protected void onCreateSchedule(ScheduleRow row) {
657         if (row.getSchedule() == null) {
658             // This row has been deleted.
659             return;
660         }
661         if (!row.isOnAir()) {
662             if (row.isScheduleCanceled()) {
663                 mDvrManager.updateScheduledRecording(
664                         ScheduledRecording.buildFrom(row.getSchedule())
665                                 .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED)
666                                 .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule()))
667                                 .build());
668                 String msg =
669                         mContext.getString(
670                                 R.string.dvr_msg_program_scheduled,
671                                 row.getSchedule().getProgramTitle());
672                 ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT);
673             } else if (mDvrManager.isConflicting(row.getSchedule())) {
674                 mDvrManager.setHighestPriority(row.getSchedule());
675             }
676         }
677     }
678 
679     /** Action handler for {@link #ACTION_REMOVE_SCHEDULE}. */
onRemoveSchedule(ScheduleRow row)680     protected void onRemoveSchedule(ScheduleRow row) {
681         if (row.getSchedule() == null) {
682             // This row has been deleted.
683             return;
684         }
685         CharSequence deletedInfo = null;
686         if (row.isOnAir()) {
687             if (row.isRecordingNotStarted()) {
688                 deletedInfo = getDeletedInfo(row);
689                 mDvrManager.removeScheduledRecording(row.getSchedule());
690             }
691         } else {
692             if (mDvrManager.isConflicting(row.getSchedule())
693                     && !shouldKeepScheduleAfterRemoving()) {
694                 deletedInfo = getDeletedInfo(row);
695                 mDvrManager.removeScheduledRecording(row.getSchedule());
696             } else if (row.isRecordingNotStarted()) {
697                 deletedInfo = getDeletedInfo(row);
698                 mDvrManager.updateScheduledRecording(
699                         ScheduledRecording.buildFrom(row.getSchedule())
700                                 .setState(ScheduledRecording.STATE_RECORDING_CANCELED)
701                                 .build());
702             } else if (row.isRecordingFailed()) {
703                 deletedInfo = getDeletedInfo(row);
704                 mDvrManager.removeScheduledRecording(row.getSchedule());
705             }
706         }
707         if (deletedInfo != null) {
708             ToastUtils.show(
709                     mContext,
710                     mContext.getResources()
711                             .getString(R.string.dvr_schedules_deletion_info, deletedInfo),
712                     Toast.LENGTH_SHORT);
713         }
714     }
715 
getDeletedInfo(ScheduleRow row)716     private CharSequence getDeletedInfo(ScheduleRow row) {
717         CharSequence deletedInfo = onGetProgramInfoText(row);
718         if (TextUtils.isEmpty(deletedInfo)) {
719             return getChannelNameText(row);
720         }
721         return deletedInfo;
722     }
723 
724     @Override
onRowViewSelected(ViewHolder vh, boolean selected)725     protected void onRowViewSelected(ViewHolder vh, boolean selected) {
726         super.onRowViewSelected(vh, selected);
727         updateActionContainer(vh, selected);
728     }
729 
730     /** Internal method for onRowViewSelected, can be customized by subclass. */
updateActionContainer(ViewHolder vh, boolean selected)731     private void updateActionContainer(ViewHolder vh, boolean selected) {
732         ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh;
733         viewHolder.mSecondActionContainer.animate().setListener(null).cancel();
734         viewHolder.mFirstActionContainer.animate().setListener(null).cancel();
735         if (selected && viewHolder.mActions != null) {
736             switch (viewHolder.mActions.length) {
737                 case 2:
738                     prepareShowActionView(viewHolder.mSecondActionContainer);
739                     prepareShowActionView(viewHolder.mFirstActionContainer);
740                     viewHolder.mPendingAnimationRunnable =
741                             () -> {
742                                 showActionView(viewHolder.mSecondActionContainer);
743                                 showActionView(viewHolder.mFirstActionContainer);
744                             };
745                     break;
746                 case 1:
747                     prepareShowActionView(viewHolder.mFirstActionContainer);
748                     viewHolder.mPendingAnimationRunnable =
749                             () -> {
750                                 hideActionView(viewHolder.mSecondActionContainer, View.GONE);
751                                 showActionView(viewHolder.mFirstActionContainer);
752                             };
753                     if (mLastFocusedViewId == R.id.action_second_container) {
754                         mLastFocusedViewId = R.id.info_container;
755                     }
756                     break;
757                 case 0:
758                 default:
759                     viewHolder.mPendingAnimationRunnable =
760                             () -> {
761                                 hideActionView(viewHolder.mSecondActionContainer, View.GONE);
762                                 hideActionView(viewHolder.mFirstActionContainer, View.GONE);
763                             };
764                     mLastFocusedViewId = R.id.info_container;
765                     SoftPreconditions.checkState(
766                             viewHolder.mInfoContainer.isFocusable(),
767                             TAG,
768                             "No focusable view in this row: " + viewHolder);
769                     break;
770             }
771             View view = viewHolder.view.findViewById(mLastFocusedViewId);
772             if (view != null && view.getVisibility() == View.VISIBLE) {
773                 // When the row is selected, information container gets the initial focus.
774                 // To give the focus to the same control as the previous row, we need to call
775                 // requestFocus() explicitly.
776                 if (view.hasFocus()) {
777                     viewHolder.mPendingAnimationRunnable.run();
778                 } else if (view.isFocusable()) {
779                     view.requestFocus();
780                 } else {
781                     viewHolder.view.requestFocus();
782                 }
783             }
784         } else {
785             viewHolder.mPendingAnimationRunnable = null;
786             hideActionView(viewHolder.mFirstActionContainer, View.GONE);
787             hideActionView(viewHolder.mSecondActionContainer, View.GONE);
788         }
789     }
790 
prepareShowActionView(View view)791     private void prepareShowActionView(View view) {
792         if (view.getVisibility() != View.VISIBLE) {
793             view.setAlpha(0.0f);
794         }
795         view.setVisibility(View.VISIBLE);
796     }
797 
798     /** Add animation when view is visible. */
showActionView(View view)799     private void showActionView(View view) {
800         view.animate()
801                 .alpha(1.0f)
802                 .setInterpolator(new DecelerateInterpolator())
803                 .setDuration(mAnimationDuration)
804                 .start();
805     }
806 
807     /** Add animation when view change to invisible. */
hideActionView(View view, int visibility)808     private void hideActionView(View view, int visibility) {
809         if (view.getVisibility() != View.VISIBLE) {
810             if (view.getVisibility() != visibility) {
811                 view.setVisibility(visibility);
812             }
813             return;
814         }
815         view.animate()
816                 .alpha(0.0f)
817                 .setInterpolator(new DecelerateInterpolator())
818                 .setDuration(mAnimationDuration)
819                 .setListener(
820                         new AnimatorListenerAdapter() {
821                             @Override
822                             public void onAnimationEnd(Animator animation) {
823                                 view.setVisibility(visibility);
824                                 view.animate().setListener(null);
825                             }
826                         })
827                 .start();
828     }
829 
830     /**
831      * Returns the available actions according to the row's state. It should be the reverse order
832      * with that in the screen.
833      */
834     @ScheduleRowAction
getAvailableActions(ScheduleRow row)835     protected int[] getAvailableActions(ScheduleRow row) {
836         if (row.getSchedule() != null) {
837             if (row.isRecordingInProgress()) {
838                 return new int[] {ACTION_STOP_RECORDING};
839             } else if (row.isOnAir() && !row.hasRecordedProgram()) {
840                 if (row.isRecordingNotStarted()) {
841                     if (canResolveConflict()) {
842                         // The "START" action can change the conflict states.
843                         return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_START_RECORDING};
844                     } else {
845                         return new int[] {ACTION_REMOVE_SCHEDULE};
846                     }
847                 } else if (row.isRecordingFinished()) {
848                     return new int[] {ACTION_START_RECORDING};
849                 } else {
850                     SoftPreconditions.checkState(
851                             false,
852                             TAG,
853                             "Invalid row state in checking the"
854                                     + " available actions(on air): "
855                                     + row);
856                 }
857             } else {
858                 if (row.isScheduleCanceled()) {
859                     return new int[] {ACTION_CREATE_SCHEDULE};
860                 } else if (mDvrManager.isConflicting(row.getSchedule()) && canResolveConflict()) {
861                     return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_CREATE_SCHEDULE};
862                 } else if (row.isRecordingNotStarted()) {
863                     return new int[] {ACTION_REMOVE_SCHEDULE};
864                 } else if (row.isRecordingFailed()) {
865                     return new int[] {ACTION_REMOVE_SCHEDULE};
866                 } else if (row.isRecordingFinished()) {
867                     return new int[] {};
868                 } else {
869                     SoftPreconditions.checkState(
870                             false,
871                             TAG,
872                             "Invalid row state in checking the"
873                                     + " available actions(future schedule): "
874                                     + row);
875                 }
876             }
877         }
878         return null;
879     }
880 
881     /** Check if the conflict can be resolved in this screen. */
canResolveConflict()882     protected boolean canResolveConflict() {
883         return true;
884     }
885 
886     /** Check if the schedule should be kept after removing it. */
shouldKeepScheduleAfterRemoving()887     protected boolean shouldKeepScheduleAfterRemoving() {
888         return false;
889     }
890 
891     /** Checks if the row should be grayed out. */
shouldBeGrayedOut(ScheduleRow row)892     protected boolean shouldBeGrayedOut(ScheduleRow row) {
893         return row.getSchedule() == null
894                 || (row.isOnAir() && !row.isRecordingInProgress() && !row.hasRecordedProgram())
895                 || mDvrManager.isConflicting(row.getSchedule())
896                 || row.isScheduleCanceled()
897                 || row.isRecordingFailed();
898     }
899 }
900