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