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