ProgramItemView.java revision 0cc0713c1bf8027642987b750b80217569d2932a
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 17package com.android.tv.guide; 18 19import android.annotation.SuppressLint; 20import android.content.Context; 21import android.content.res.ColorStateList; 22import android.content.res.Resources; 23import android.graphics.drawable.Drawable; 24import android.graphics.drawable.LayerDrawable; 25import android.graphics.drawable.StateListDrawable; 26import android.os.Handler; 27import android.text.SpannableStringBuilder; 28import android.text.Spanned; 29import android.text.TextUtils; 30import android.text.style.TextAppearanceSpan; 31import android.util.AttributeSet; 32import android.util.Log; 33import android.view.View; 34import android.view.ViewGroup; 35import android.widget.TextView; 36import android.widget.Toast; 37import com.android.tv.MainActivity; 38import com.android.tv.R; 39import com.android.tv.TvSingletons; 40import com.android.tv.analytics.Tracker; 41import com.android.tv.common.feature.CommonFeatures; 42import com.android.tv.common.util.Clock; 43import com.android.tv.data.ChannelDataManager; 44import com.android.tv.data.Program; 45import com.android.tv.data.api.Channel; 46import com.android.tv.dvr.DvrManager; 47import com.android.tv.dvr.data.ScheduledRecording; 48import com.android.tv.dvr.ui.DvrUiHelper; 49import com.android.tv.guide.ProgramManager.TableEntry; 50import com.android.tv.util.ToastUtils; 51import com.android.tv.util.Utils; 52import java.lang.reflect.InvocationTargetException; 53import java.util.concurrent.TimeUnit; 54 55public 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 202 public ProgramItemView(Context context) { 203 this(context, null); 204 } 205 206 public ProgramItemView(Context context, AttributeSet attrs) { 207 this(context, attrs, 0); 208 } 209 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 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 269 protected void onFinishInflate() { 270 super.onFinishInflate(); 271 initIfNeeded(); 272 } 273 274 @Override 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 291 public TableEntry getTableEntry() { 292 return mTableEntry; 293 } 294 295 @SuppressLint("SwitchIntDef") 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 328 private boolean isEntryWideEnough() { 329 return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold; 330 } 331 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 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 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. */ 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 */ 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 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 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 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 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 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 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