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