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.content.Context; 20 import android.content.res.ColorStateList; 21 import android.content.res.Resources; 22 import android.graphics.drawable.Drawable; 23 import android.graphics.drawable.LayerDrawable; 24 import android.graphics.drawable.StateListDrawable; 25 import android.os.Handler; 26 import android.os.SystemClock; 27 import android.support.v4.os.BuildCompat; 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 38 import com.android.tv.ApplicationSingletons; 39 import com.android.tv.MainActivity; 40 import com.android.tv.R; 41 import com.android.tv.TvApplication; 42 import com.android.tv.analytics.Tracker; 43 import com.android.tv.common.feature.CommonFeatures; 44 import com.android.tv.data.Channel; 45 import com.android.tv.dvr.DvrManager; 46 import com.android.tv.dvr.ui.DvrDialogFragment; 47 import com.android.tv.dvr.ui.DvrRecordDeleteFragment; 48 import com.android.tv.dvr.ui.DvrRecordScheduleFragment; 49 import com.android.tv.guide.ProgramManager.TableEntry; 50 import com.android.tv.util.Utils; 51 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 TextAppearanceSpan sProgramTitleStyle; 70 private static TextAppearanceSpan sGrayedOutProgramTitleStyle; 71 private static TextAppearanceSpan sEpisodeTitleStyle; 72 private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; 73 74 private TableEntry mTableEntry; 75 private int mMaxWidthForRipple; 76 private int mTextWidth; 77 78 // If set this flag disables requests to re-layout the parent view as a result of changing 79 // this view, improving performance. This also prevents the parent view to lose child focus 80 // as a result of the re-layout (see b/21378855). 81 private boolean mPreventParentRelayout; 82 83 private static final View.OnClickListener ON_CLICKED = new View.OnClickListener() { 84 @Override 85 public void onClick(final View view) { 86 TableEntry entry = ((ProgramItemView) view).mTableEntry; 87 if (entry == null) { 88 //do nothing 89 return; 90 } 91 ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext()); 92 Tracker tracker = singletons.getTracker(); 93 tracker.sendEpgItemClicked(); 94 if (entry.isCurrentProgram()) { 95 final MainActivity tvActivity = (MainActivity) view.getContext(); 96 final Channel channel = tvActivity.getChannelDataManager() 97 .getChannel(entry.channelId); 98 view.postDelayed(new Runnable() { 99 @Override 100 public void run() { 101 tvActivity.tuneToChannel(channel); 102 tvActivity.hideOverlaysForTune(); 103 } 104 }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0 105 : view.getResources() 106 .getInteger(R.integer.program_guide_ripple_anim_duration)); 107 } else if (CommonFeatures.DVR.isEnabled(view.getContext()) && BuildCompat 108 .isAtLeastN()) { 109 final MainActivity tvActivity = (MainActivity) view.getContext(); 110 final DvrManager dvrManager = singletons.getDvrManager(); 111 final Channel channel = tvActivity.getChannelDataManager() 112 .getChannel(entry.channelId); 113 if (dvrManager.canRecord(channel.getInputId()) && entry.program != null) { 114 if (entry.scheduledRecording == null) { 115 showDvrDialog(view, entry); 116 } else { 117 showRecordDeleteDialog(view, entry); 118 } 119 } 120 } 121 } 122 123 private void showDvrDialog(final View view, TableEntry entry) { 124 Utils.showToastMessageForDeveloperFeature(view.getContext()); 125 DvrRecordScheduleFragment dvrRecordScheduleFragment = 126 new DvrRecordScheduleFragment(entry); 127 DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(dvrRecordScheduleFragment); 128 ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment( 129 DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true); 130 } 131 132 private void showRecordDeleteDialog(final View view, final TableEntry entry) { 133 DvrRecordDeleteFragment recordDeleteDialogFragment = new DvrRecordDeleteFragment(entry); 134 DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(recordDeleteDialogFragment); 135 ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment( 136 DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true); 137 } 138 }; 139 140 private static final View.OnFocusChangeListener ON_FOCUS_CHANGED = 141 new View.OnFocusChangeListener() { 142 @Override 143 public void onFocusChange(View view, boolean hasFocus) { 144 if (hasFocus) { 145 ((ProgramItemView) view).mUpdateFocus.run(); 146 } else { 147 Handler handler = view.getHandler(); 148 if (handler != null) { 149 handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus); 150 } 151 } 152 } 153 }; 154 155 private final Runnable mUpdateFocus = new Runnable() { 156 @Override 157 public void run() { 158 refreshDrawableState(); 159 TableEntry entry = mTableEntry; 160 if (entry == null) { 161 //do nothing 162 return; 163 } 164 if (entry.isCurrentProgram()) { 165 Drawable background = getBackground(); 166 int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis); 167 setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress); 168 } 169 if (getHandler() != null) { 170 getHandler().postAtTime(this, 171 Utils.ceilTime(SystemClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY)); 172 } 173 } 174 }; 175 176 public ProgramItemView(Context context) { 177 this(context, null); 178 } 179 180 public ProgramItemView(Context context, AttributeSet attrs) { 181 this(context, attrs, 0); 182 } 183 184 public ProgramItemView(Context context, AttributeSet attrs, int defStyle) { 185 super(context, attrs, defStyle); 186 setOnClickListener(ON_CLICKED); 187 setOnFocusChangeListener(ON_FOCUS_CHANGED); 188 } 189 190 private void initIfNeeded() { 191 if (sVisibleThreshold != 0) { 192 return; 193 } 194 Resources res = getContext().getResources(); 195 196 sVisibleThreshold = res.getDimensionPixelOffset( 197 R.dimen.program_guide_table_item_visible_threshold); 198 199 sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding); 200 201 ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res, 202 R.color.program_guide_table_item_program_title_text_color)); 203 ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res, 204 R.color.program_guide_table_item_grayed_out_program_text_color); 205 ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, 206 R.color.program_guide_table_item_program_episode_title_text_color)); 207 ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, 208 R.color.program_guide_table_item_grayed_out_program_episode_title_text_color)); 209 int programTitleSize = res.getDimensionPixelSize( 210 R.dimen.program_guide_table_item_program_title_font_size); 211 int episodeTitleSize = res.getDimensionPixelSize( 212 R.dimen.program_guide_table_item_program_episode_title_font_size); 213 214 sProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, 215 null); 216 sGrayedOutProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, 217 grayedOutProgramTitleColor, null); 218 sEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, 219 null); 220 sGrayedOutEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, 221 grayedOutEpisodeTitleColor, null); 222 } 223 224 @Override 225 protected void onFinishInflate() { 226 super.onFinishInflate(); 227 initIfNeeded(); 228 } 229 230 @Override 231 protected int[] onCreateDrawableState(int extraSpace) { 232 if (mTableEntry != null) { 233 int states[] = super.onCreateDrawableState(extraSpace 234 + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length); 235 if (mTableEntry.isCurrentProgram()) { 236 mergeDrawableStates(states, STATE_CURRENT_PROGRAM); 237 } 238 if (mTableEntry.getWidth() > mMaxWidthForRipple) { 239 mergeDrawableStates(states, STATE_TOO_WIDE); 240 } 241 return states; 242 } 243 return super.onCreateDrawableState(extraSpace); 244 } 245 246 public TableEntry getTableEntry() { 247 return mTableEntry; 248 } 249 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 if (mTableEntry.scheduledRecording != null) { 279 //TODO(dvr): use a proper icon for UI status. 280 title = "" + title; 281 } 282 283 SpannableStringBuilder description = new SpannableStringBuilder(); 284 description.append(title); 285 if (!TextUtils.isEmpty(episode)) { 286 description.append('\n'); 287 288 // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for 289 // all lines. This is a non-printing character so it will not change the horizontal 290 // spacing however it will affect the line height. As we ensure the ZWJ has the same 291 // text style as the title it will make sure the line height is consistent. 292 description.append('\u200D'); 293 294 int middle = description.length(); 295 description.append(episode); 296 297 description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 298 description.setSpan(episodeStyle, middle, description.length(), 299 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 300 } else { 301 description.setSpan(titleStyle, 0, description.length(), 302 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 303 } 304 setText(description); 305 } 306 measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 307 mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd(); 308 int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis); 309 int guideStart = GuideUtils.convertMillisToPixel(fromUtcMillis); 310 layoutVisibleArea(guideStart - start); 311 312 // Maximum width for us to use a ripple 313 mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis); 314 } 315 316 /** 317 * Layout title and episode according to visible area. 318 * 319 * Here's the spec. 320 * 1. Don't show text if it's shorter than 48dp. 321 * 2. Try showing whole text in visible area by placing and wrapping text, 322 * but do not wrap text less than 30min. 323 * 3. Episode title is visible only if title isn't multi-line. 324 * 325 * @param offset Offset of the start position from the enclosing view's start position. 326 */ 327 public void layoutVisibleArea(int offset) { 328 int width = mTableEntry.getWidth(); 329 int startPadding = Math.max(0, offset); 330 int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding); 331 if (startPadding > 0 && width - startPadding < minWidth) { 332 startPadding = Math.max(0, width - minWidth); 333 } 334 335 if (startPadding + sItemPadding != getPaddingStart()) { 336 mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent. 337 setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0); 338 mPreventParentRelayout = false; 339 } 340 } 341 342 public void clearValues() { 343 if (getHandler() != null) { 344 getHandler().removeCallbacks(mUpdateFocus); 345 } 346 347 setTag(null); 348 mTableEntry = null; 349 } 350 351 private static int getProgress(long start, long end) { 352 long currentTime = System.currentTimeMillis(); 353 if (currentTime <= start) { 354 return 0; 355 } else if (currentTime >= end) { 356 return MAX_PROGRESS; 357 } 358 return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start)); 359 } 360 361 private static void setProgress(Drawable drawable, int id, int progress) { 362 if (drawable instanceof StateListDrawable) { 363 StateListDrawable stateDrawable = (StateListDrawable) drawable; 364 for (int i = 0; i < getStateCount(stateDrawable); ++i) { 365 setProgress(getStateDrawable(stateDrawable, i), id, progress); 366 } 367 } else if (drawable instanceof LayerDrawable) { 368 LayerDrawable layerDrawable = (LayerDrawable) drawable; 369 for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) { 370 setProgress(layerDrawable.getDrawable(i), id, progress); 371 if (layerDrawable.getId(i) == id) { 372 layerDrawable.getDrawable(i).setLevel(progress); 373 } 374 } 375 } 376 } 377 378 private static int getStateCount(StateListDrawable stateListDrawable) { 379 try { 380 Object stateCount = StateListDrawable.class.getDeclaredMethod("getStateCount") 381 .invoke(stateListDrawable); 382 return (int) stateCount; 383 } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException 384 |InvocationTargetException e) { 385 Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e); 386 return 0; 387 } 388 } 389 390 private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) { 391 try { 392 Object drawable = StateListDrawable.class 393 .getDeclaredMethod("getStateDrawable", Integer.TYPE) 394 .invoke(stateListDrawable, index); 395 return (Drawable) drawable; 396 } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException 397 |InvocationTargetException e) { 398 Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e); 399 return null; 400 } 401 } 402 403 @Override 404 public void requestLayout() { 405 if (mPreventParentRelayout) { 406 // Trivial layout, no need to tell parent. 407 forceLayout(); 408 } else { 409 super.requestLayout(); 410 } 411 } 412 } 413