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.annotation.SuppressLint; 20 import android.content.Context; 21 import android.content.res.ColorStateList; 22 import android.content.res.Resources; 23 import android.graphics.drawable.Drawable; 24 import android.graphics.drawable.LayerDrawable; 25 import android.graphics.drawable.StateListDrawable; 26 import android.os.Build; 27 import android.os.Handler; 28 import android.text.SpannableStringBuilder; 29 import android.text.Spanned; 30 import android.text.TextUtils; 31 import android.text.style.TextAppearanceSpan; 32 import android.util.AttributeSet; 33 import android.util.Log; 34 import android.view.View; 35 import android.view.ViewGroup; 36 import android.widget.TextView; 37 import android.widget.Toast; 38 39 import com.android.tv.MainActivity; 40 import com.android.tv.R; 41 import com.android.tv.TvSingletons; 42 import com.android.tv.analytics.Tracker; 43 import com.android.tv.common.feature.CommonFeatures; 44 import com.android.tv.common.flags.DvrFlags; 45 import com.android.tv.common.util.Clock; 46 import com.android.tv.data.ChannelDataManager; 47 import com.android.tv.data.api.Channel; 48 import com.android.tv.data.api.Program; 49 import com.android.tv.dvr.DvrManager; 50 import com.android.tv.dvr.data.ScheduledRecording; 51 import com.android.tv.dvr.ui.DvrUiHelper; 52 import com.android.tv.guide.ProgramManager.TableEntry; 53 import com.android.tv.util.ToastUtils; 54 import com.android.tv.util.Utils; 55 56 import dagger.android.HasAndroidInjector; 57 58 import java.lang.reflect.InvocationTargetException; 59 import java.util.concurrent.TimeUnit; 60 61 import javax.inject.Inject; 62 63 public class ProgramItemView extends TextView { 64 private static final String TAG = "ProgramItemView"; 65 66 private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1); 67 private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE 68 69 // State indicating the focused program is the current program 70 private static final int[] STATE_CURRENT_PROGRAM = {R.attr.state_current_program}; 71 72 // Workaround state in order to not use too much texture memory for RippleDrawable 73 private static final int[] STATE_TOO_WIDE = {R.attr.state_program_too_wide}; 74 75 private static int sVisibleThreshold; 76 private static int sItemPadding; 77 private static int sCompoundDrawablePadding; 78 private static TextAppearanceSpan sProgramTitleStyle; 79 private static TextAppearanceSpan sGrayedOutProgramTitleStyle; 80 private static TextAppearanceSpan sEpisodeTitleStyle; 81 private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; 82 83 private final DvrManager mDvrManager; 84 @Inject Clock mClock; 85 @Inject ChannelDataManager mChannelDataManager; 86 @Inject DvrFlags mDvrFlags; 87 private ProgramGuide mProgramGuide; 88 private TableEntry mTableEntry; 89 private int mMaxWidthForRipple; 90 private int mTextWidth; 91 92 // If set this flag disables requests to re-layout the parent view as a result of changing 93 // this view, improving performance. This also prevents the parent view to lose child focus 94 // as a result of the re-layout (see b/21378855). 95 private boolean mPreventParentRelayout; 96 97 private static final View.OnClickListener ON_CLICKED = 98 new View.OnClickListener() { 99 @Override 100 public void onClick(final View view) { 101 TableEntry entry = ((ProgramItemView) view).mTableEntry; 102 Clock clock = ((ProgramItemView) view).mClock; 103 DvrFlags dvrFlags = ((ProgramItemView) view).mDvrFlags; 104 if (entry == null) { 105 // do nothing 106 return; 107 } 108 TvSingletons singletons = TvSingletons.getSingletons(view.getContext()); 109 Tracker tracker = singletons.getTracker(); 110 tracker.sendEpgItemClicked(); 111 final MainActivity tvActivity = (MainActivity) view.getContext(); 112 final Channel channel = 113 tvActivity.getChannelDataManager().getChannel(entry.channelId); 114 if (entry.isCurrentProgram()) { 115 view.postDelayed( 116 () -> { 117 tvActivity.tuneToChannel(channel); 118 tvActivity.hideOverlaysForTune(); 119 }, 120 entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple 121 ? 0 122 : view.getResources() 123 .getInteger( 124 R.integer 125 .program_guide_ripple_anim_duration)); 126 } else if (entry.program != null 127 && CommonFeatures.DVR.isEnabled(view.getContext())) { 128 DvrManager dvrManager = singletons.getDvrManager(); 129 if (entry.entryStartUtcMillis > clock.currentTimeMillis() 130 && dvrManager.isProgramRecordable(entry.program)) { 131 if (entry.scheduledRecording == null) { 132 if (!entry.program.isEpisodic() && 133 dvrFlags.startEarlyEndLateEnabled()) { 134 DvrUiHelper.startRecordingSettingsActivity(view.getContext(), 135 entry.program); 136 } else { 137 DvrUiHelper.checkStorageStatusAndShowErrorMessage( 138 tvActivity, 139 channel.getInputId(), 140 () -> 141 DvrUiHelper.requestRecordingFutureProgram( 142 tvActivity, entry.program, false)); 143 } 144 } else { 145 dvrManager.removeScheduledRecording(entry.scheduledRecording); 146 String msg = 147 view.getResources() 148 .getString( 149 R.string.dvr_schedules_deletion_info, 150 entry.program.getTitle()); 151 ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT); 152 } 153 } else { 154 ToastUtils.show( 155 view.getContext(), 156 view.getResources() 157 .getString(R.string.dvr_msg_cannot_record_program), 158 Toast.LENGTH_SHORT); 159 } 160 } 161 } 162 }; 163 164 private static final View.OnFocusChangeListener ON_FOCUS_CHANGED = 165 new View.OnFocusChangeListener() { 166 @Override 167 public void onFocusChange(View view, boolean hasFocus) { 168 if (hasFocus) { 169 ((ProgramItemView) view).mUpdateFocus.run(); 170 } else { 171 Handler handler = view.getHandler(); 172 if (handler != null) { 173 handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus); 174 } 175 } 176 } 177 }; 178 179 private final Runnable mUpdateFocus = 180 new Runnable() { 181 @Override 182 public void run() { 183 refreshDrawableState(); 184 TableEntry entry = mTableEntry; 185 if (entry == null) { 186 // do nothing 187 return; 188 } 189 if (entry.isCurrentProgram()) { 190 Drawable background = getBackground(); 191 if (!mProgramGuide.isActive() || mProgramGuide.isRunningAnimation()) { 192 // If program guide is not active or is during showing/hiding, 193 // the animation is unnecessary, skip it. 194 background.jumpToCurrentState(); 195 } 196 int progress = 197 getProgress( 198 mClock, entry.entryStartUtcMillis, entry.entryEndUtcMillis); 199 setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress); 200 } 201 if (getHandler() != null) { 202 getHandler() 203 .postAtTime( 204 this, 205 Utils.ceilTime( 206 mClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY)); 207 } 208 } 209 }; 210 ProgramItemView(Context context)211 public ProgramItemView(Context context) { 212 this(context, null); 213 } 214 ProgramItemView(Context context, AttributeSet attrs)215 public ProgramItemView(Context context, AttributeSet attrs) { 216 this(context, attrs, 0); 217 } 218 ProgramItemView(Context context, AttributeSet attrs, int defStyle)219 public ProgramItemView(Context context, AttributeSet attrs, int defStyle) { 220 super(context, attrs, defStyle); 221 ((HasAndroidInjector) context).androidInjector().inject(this); 222 setOnClickListener(ON_CLICKED); 223 setOnFocusChangeListener(ON_FOCUS_CHANGED); 224 TvSingletons singletons = TvSingletons.getSingletons(getContext()); 225 mDvrManager = singletons.getDvrManager(); 226 } 227 initIfNeeded()228 private void initIfNeeded() { 229 if (sVisibleThreshold != 0) { 230 return; 231 } 232 Resources res = getContext().getResources(); 233 234 sVisibleThreshold = 235 res.getDimensionPixelOffset(R.dimen.program_guide_table_item_visible_threshold); 236 237 sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding); 238 sCompoundDrawablePadding = 239 res.getDimensionPixelOffset( 240 R.dimen.program_guide_table_item_compound_drawable_padding); 241 242 ColorStateList programTitleColor = 243 ColorStateList.valueOf( 244 res.getColor( 245 R.color.program_guide_table_item_program_title_text_color, null)); 246 ColorStateList grayedOutProgramTitleColor = 247 res.getColorStateList( 248 R.color.program_guide_table_item_grayed_out_program_text_color, null); 249 ColorStateList episodeTitleColor = 250 ColorStateList.valueOf( 251 res.getColor( 252 R.color.program_guide_table_item_program_episode_title_text_color, 253 null)); 254 ColorStateList grayedOutEpisodeTitleColor = 255 ColorStateList.valueOf( 256 res.getColor( 257 R.color 258 .program_guide_table_item_grayed_out_program_episode_title_text_color, 259 null)); 260 int programTitleSize = 261 res.getDimensionPixelSize(R.dimen.program_guide_table_item_program_title_font_size); 262 int episodeTitleSize = 263 res.getDimensionPixelSize( 264 R.dimen.program_guide_table_item_program_episode_title_font_size); 265 266 sProgramTitleStyle = 267 new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, null); 268 sGrayedOutProgramTitleStyle = 269 new TextAppearanceSpan(null, 0, programTitleSize, grayedOutProgramTitleColor, null); 270 sEpisodeTitleStyle = 271 new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, null); 272 sGrayedOutEpisodeTitleStyle = 273 new TextAppearanceSpan(null, 0, episodeTitleSize, grayedOutEpisodeTitleColor, null); 274 } 275 276 @Override onFinishInflate()277 protected void onFinishInflate() { 278 super.onFinishInflate(); 279 initIfNeeded(); 280 } 281 282 @Override onCreateDrawableState(int extraSpace)283 protected int[] onCreateDrawableState(int extraSpace) { 284 if (mTableEntry != null) { 285 int[] states = 286 super.onCreateDrawableState( 287 extraSpace + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length); 288 if (mTableEntry.isCurrentProgram()) { 289 mergeDrawableStates(states, STATE_CURRENT_PROGRAM); 290 } 291 if (mTableEntry.getWidth() > mMaxWidthForRipple) { 292 mergeDrawableStates(states, STATE_TOO_WIDE); 293 } 294 return states; 295 } 296 return super.onCreateDrawableState(extraSpace); 297 } 298 getTableEntry()299 public TableEntry getTableEntry() { 300 return mTableEntry; 301 } 302 303 @SuppressLint("SwitchIntDef") setValues( ProgramGuide programGuide, TableEntry entry, int selectedGenreId, long fromUtcMillis, long toUtcMillis, String gapTitle)304 public void setValues( 305 ProgramGuide programGuide, 306 TableEntry entry, 307 int selectedGenreId, 308 long fromUtcMillis, 309 long toUtcMillis, 310 String gapTitle) { 311 mProgramGuide = programGuide; 312 mTableEntry = entry; 313 314 ViewGroup.LayoutParams layoutParams = getLayoutParams(); 315 if (layoutParams != null) { 316 // There is no layoutParams in the tests so we skip this 317 layoutParams.width = entry.getWidth(); 318 setLayoutParams(layoutParams); 319 } 320 String title = mTableEntry.program != null ? mTableEntry.program.getTitle() : null; 321 if (mTableEntry.isGap()) { 322 title = gapTitle; 323 } 324 if (TextUtils.isEmpty(title)) { 325 title = getResources().getString(R.string.program_title_for_no_information); 326 } 327 updateText(selectedGenreId, title); 328 updateIcons(); 329 updateContentDescription(title); 330 measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 331 mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd(); 332 // Maximum width for us to use a ripple 333 mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis); 334 } 335 isEntryWideEnough()336 private boolean isEntryWideEnough() { 337 return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold; 338 } 339 updateText(int selectedGenreId, String title)340 private void updateText(int selectedGenreId, String title) { 341 if (!isEntryWideEnough()) { 342 setText(null); 343 return; 344 } 345 346 String episode = 347 mTableEntry.program != null 348 ? mTableEntry.program.getEpisodeDisplayTitle(getContext()) 349 : null; 350 351 TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle; 352 TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle; 353 if (mTableEntry.isGap()) { 354 355 episode = null; 356 } else if (mTableEntry.hasGenre(selectedGenreId)) { 357 titleStyle = sProgramTitleStyle; 358 episodeStyle = sEpisodeTitleStyle; 359 } 360 SpannableStringBuilder description = new SpannableStringBuilder(); 361 description.append(title); 362 if (!TextUtils.isEmpty(episode)) { 363 description.append('\n'); 364 365 // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for 366 // all lines. This is a non-printing character so it will not change the horizontal 367 // spacing however it will affect the line height. As we ensure the ZWJ has the same 368 // text style as the title it will make sure the line height is consistent. 369 description.append('\u200D'); 370 371 int middle = description.length(); 372 description.append(episode); 373 374 description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 375 description.setSpan( 376 episodeStyle, middle, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 377 } else { 378 description.setSpan( 379 titleStyle, 0, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 380 } 381 setText(description); 382 } 383 updateIcons()384 private void updateIcons() { 385 // Sets recording icons if needed. 386 int iconResId = 0; 387 if (isEntryWideEnough() && mTableEntry.scheduledRecording != null) { 388 if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) { 389 iconResId = R.drawable.quantum_ic_warning_white_18; 390 } else { 391 switch (mTableEntry.scheduledRecording.getState()) { 392 case ScheduledRecording.STATE_RECORDING_NOT_STARTED: 393 iconResId = R.drawable.ic_scheduled_recording; 394 break; 395 case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: 396 iconResId = R.drawable.ic_recording_program; 397 break; 398 default: 399 // leave the iconResId=0 400 } 401 } 402 } 403 setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0); 404 setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0); 405 } 406 updateContentDescription(String title)407 private void updateContentDescription(String title) { 408 // The content description includes extra information that is displayed on the detail view 409 Resources resources = getResources(); 410 String description = title; 411 // TODO(b/73282818): only say channel name when the row changes 412 Channel channel = mChannelDataManager.getChannel(mTableEntry.channelId); 413 if (channel != null) { 414 description = channel.getDisplayNumber() + " " + description; 415 } 416 Program program = mTableEntry.program; 417 if (program != null) { 418 description += " " + program.getDurationString(getContext()); 419 String episodeDescription = program.getEpisodeContentDescription(getContext()); 420 if (!TextUtils.isEmpty(episodeDescription)) { 421 description += " " + episodeDescription; 422 } 423 } else { 424 description += 425 " " 426 + Utils.getDurationString( 427 getContext(), 428 mClock, 429 mTableEntry.entryStartUtcMillis, 430 mTableEntry.entryEndUtcMillis, 431 true); 432 } 433 if (mTableEntry.scheduledRecording != null) { 434 if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) { 435 description += 436 " " + resources.getString(R.string.dvr_epg_program_recording_conflict); 437 } else { 438 switch (mTableEntry.scheduledRecording.getState()) { 439 case ScheduledRecording.STATE_RECORDING_NOT_STARTED: 440 description += 441 " " 442 + resources.getString( 443 R.string.dvr_epg_program_recording_scheduled); 444 break; 445 case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: 446 description += 447 " " 448 + resources.getString( 449 R.string.dvr_epg_program_recording_in_progress); 450 break; 451 default: 452 // do nothing 453 } 454 } 455 } 456 if (mTableEntry.isBlocked()) { 457 description += " " + resources.getString(R.string.program_guide_content_locked); 458 } else if (program != null) { 459 String programDescription = program.getDescription(); 460 if (!TextUtils.isEmpty(programDescription)) { 461 description += " " + programDescription; 462 } 463 } 464 setContentDescription(description); 465 } 466 467 /** Update programItemView to handle alignments of text. */ updateVisibleArea()468 public void updateVisibleArea() { 469 View parentView = ((View) getParent()); 470 if (parentView == null) { 471 return; 472 } 473 if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) { 474 layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight()); 475 } else { 476 layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft()); 477 } 478 } 479 480 /** 481 * Layout title and episode according to visible area. 482 * 483 * <p>Here's the spec. 1. Don't show text if it's shorter than 48dp. 2. Try showing whole text 484 * in visible area by placing and wrapping text, but do not wrap text less than 30min. 3. 485 * Episode title is visible only if title isn't multi-line. 486 * 487 * @param startOffset Offset of the start position from the enclosing view's start position. 488 * @param endOffset Offset of the end position from the enclosing view's end position. 489 */ layoutVisibleArea(int startOffset, int endOffset)490 private void layoutVisibleArea(int startOffset, int endOffset) { 491 int width = mTableEntry.getWidth(); 492 int startPadding = Math.max(0, startOffset); 493 int endPadding = Math.max(0, endOffset); 494 int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding); 495 if (startPadding > 0 && width - startPadding < minWidth) { 496 startPadding = Math.max(0, width - minWidth); 497 } 498 if (endPadding > 0 && width - endPadding < minWidth) { 499 endPadding = Math.max(0, width - minWidth); 500 } 501 502 if (startPadding + sItemPadding != getPaddingStart() 503 || endPadding + sItemPadding != getPaddingEnd()) { 504 mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent. 505 setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0); 506 mPreventParentRelayout = false; 507 } 508 } 509 clearValues()510 public void clearValues() { 511 if (getHandler() != null) { 512 getHandler().removeCallbacks(mUpdateFocus); 513 } 514 515 setTag(null); 516 mProgramGuide = null; 517 mTableEntry = null; 518 } 519 getProgress(Clock clock, long start, long end)520 private static int getProgress(Clock clock, long start, long end) { 521 long currentTime = clock.currentTimeMillis(); 522 if (currentTime <= start) { 523 return 0; 524 } else if (currentTime >= end) { 525 return MAX_PROGRESS; 526 } 527 return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start)); 528 } 529 setProgress(Drawable drawable, int id, int progress)530 private static void setProgress(Drawable drawable, int id, int progress) { 531 if (drawable instanceof StateListDrawable) { 532 StateListDrawable stateDrawable = (StateListDrawable) drawable; 533 for (int i = 0; i < getStateCount(stateDrawable); ++i) { 534 setProgress(getStateDrawable(stateDrawable, i), id, progress); 535 } 536 } else if (drawable instanceof LayerDrawable) { 537 LayerDrawable layerDrawable = (LayerDrawable) drawable; 538 for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) { 539 setProgress(layerDrawable.getDrawable(i), id, progress); 540 if (layerDrawable.getId(i) == id) { 541 layerDrawable.getDrawable(i).setLevel(progress); 542 } 543 } 544 } 545 } 546 getStateCount(StateListDrawable stateListDrawable)547 private static int getStateCount(StateListDrawable stateListDrawable) { 548 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 549 return stateListDrawable.getStateCount(); 550 } 551 try { 552 Object stateCount = 553 StateListDrawable.class 554 .getDeclaredMethod("getStateCount") 555 .invoke(stateListDrawable); 556 return (int) stateCount; 557 } catch (NoSuchMethodException 558 | IllegalAccessException 559 | IllegalArgumentException 560 | InvocationTargetException e) { 561 Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e); 562 return 0; 563 } 564 } 565 getStateDrawable(StateListDrawable stateListDrawable, int index)566 private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) { 567 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 568 return stateListDrawable.getStateDrawable(index); 569 } 570 try { 571 Object drawable = 572 StateListDrawable.class 573 .getDeclaredMethod("getStateDrawable", Integer.TYPE) 574 .invoke(stateListDrawable, index); 575 return (Drawable) drawable; 576 } catch (NoSuchMethodException 577 | IllegalAccessException 578 | IllegalArgumentException 579 | InvocationTargetException e) { 580 Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e); 581 return null; 582 } 583 } 584 585 @Override requestLayout()586 public void requestLayout() { 587 if (mPreventParentRelayout) { 588 // Trivial layout, no need to tell parent. 589 forceLayout(); 590 } else { 591 super.requestLayout(); 592 } 593 } 594 } 595