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