ProgramItemView.java revision ba5845f23b8fbc985890f892961abc8b39886611
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.app.AlertDialog; 20import android.content.Context; 21import android.content.DialogInterface; 22import android.content.res.ColorStateList; 23import android.content.res.Resources; 24import android.graphics.drawable.Drawable; 25import android.graphics.drawable.LayerDrawable; 26import android.graphics.drawable.StateListDrawable; 27import android.os.Handler; 28import android.os.SystemClock; 29import android.text.SpannableStringBuilder; 30import android.text.Spanned; 31import android.text.TextUtils; 32import android.text.style.TextAppearanceSpan; 33import android.util.AttributeSet; 34import android.util.Log; 35import android.view.View; 36import android.view.ViewGroup; 37import android.widget.TextView; 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.data.Program; 47import com.android.tv.dvr.DvrManager; 48import com.android.tv.dvr.Recording; 49import com.android.tv.guide.ProgramManager.TableEntry; 50import com.android.tv.util.Utils; 51 52import java.lang.reflect.InvocationTargetException; 53import java.util.ArrayList; 54import java.util.List; 55import java.util.concurrent.TimeUnit; 56 57public class ProgramItemView extends TextView { 58 private static final String TAG = "ProgramItemView"; 59 60 private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1); 61 private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE 62 63 private static final int ACTION_RECORD_PROGRAM = 100; 64 private static final int ACTION_RECORD_SEASON = 101; 65 66 // State indicating the focused program is the current program 67 private static final int[] STATE_CURRENT_PROGRAM = { R.attr.state_current_program }; 68 69 // Workaround state in order to not use too much texture memory for RippleDrawable 70 private static final int[] STATE_TOO_WIDE = { R.attr.state_program_too_wide }; 71 72 private static int sVisibleThreshold; 73 private static int sItemPadding; 74 private static TextAppearanceSpan sProgramTitleStyle; 75 private static TextAppearanceSpan sGrayedOutProgramTitleStyle; 76 private static TextAppearanceSpan sEpisodeTitleStyle; 77 private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; 78 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 = new View.OnClickListener() { 89 @Override 90 public void onClick(final View view) { 91 TableEntry entry = ((ProgramItemView) view).mTableEntry; 92 ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext()); 93 Tracker tracker = singletons.getTracker(); 94 tracker.sendEpgItemClicked(); 95 if (entry.isCurrentProgram()) { 96 final MainActivity tvActivity = (MainActivity) view.getContext(); 97 final Channel channel = tvActivity.getChannelDataManager() 98 .getChannel(entry.channelId); 99 view.postDelayed(new Runnable() { 100 @Override 101 public void run() { 102 tvActivity.tuneToChannel(channel); 103 tvActivity.hideOverlaysForTune(); 104 } 105 }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0 106 : view.getResources() 107 .getInteger(R.integer.program_guide_ripple_anim_duration)); 108 } else if (CommonFeatures.DVR.isEnabled(view.getContext())) { 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())) { 114 showDvrDialog(view, entry, dvrManager); 115 } 116 } 117 } 118 119 private void showDvrDialog(final View view, TableEntry entry, final DvrManager dvrManager) { 120 List<CharSequence> items = new ArrayList<>(); 121 final List<Integer> actions = new ArrayList<>(); 122 // TODO: the items can be changed by the state of the program. For example, 123 // if the program is already added in scheduler, we need to show an item to 124 // delete the recording schedule. 125 items.add(view.getResources().getString(R.string.epg_dvr_record_program)); 126 actions.add(ACTION_RECORD_PROGRAM); 127 items.add(view.getResources().getString(R.string.epg_dvr_record_season)); 128 actions.add(ACTION_RECORD_SEASON); 129 130 final Program program = entry.program; 131 final List<Recording> conflicts = dvrManager 132 .getScheduledRecordingsThatConflict(program); 133 // TODO: it is a tentative UI. Don't publish the UI. 134 DialogInterface.OnClickListener onClickListener 135 = new DialogInterface.OnClickListener() { 136 @Override 137 public void onClick(final DialogInterface dialog, int which) { 138 if (actions.get(which) == ACTION_RECORD_PROGRAM) { 139 if (conflicts.isEmpty()) { 140 dvrManager.addSchedule(program, conflicts); 141 } else { 142 showConflictDialog(view, dvrManager, program, conflicts); 143 } 144 } else if (actions.get(which) == ACTION_RECORD_SEASON) { 145 dvrManager.addSeasonSchedule(program); 146 } 147 dialog.dismiss(); 148 } 149 }; 150 new AlertDialog.Builder(view.getContext()) 151 .setItems(items.toArray(new CharSequence[items.size()]), onClickListener) 152 .create() 153 .show(); 154 } 155 }; 156 157 private static void showConflictDialog(final View view, final DvrManager dvrManager, 158 final Program program, final List<Recording> conflicts) { 159 DialogInterface.OnClickListener conflictClickListener 160 = new DialogInterface.OnClickListener() { 161 @Override 162 public void onClick(DialogInterface dialog, int which) { 163 if (which == AlertDialog.BUTTON_POSITIVE) { 164 dvrManager.addSchedule(program, conflicts); 165 dialog.dismiss(); 166 } 167 } 168 }; 169 StringBuilder sb = new StringBuilder(); 170 for (Recording r : conflicts) { 171 sb.append(r.toString()).append('\n'); 172 } 173 new AlertDialog.Builder(view.getContext()).setTitle(R.string.dvr_epg_conflict_dialog_title) 174 .setMessage(sb.toString()) 175 .setPositiveButton(R.string.dvr_epg_record, conflictClickListener) 176 .setNegativeButton(R.string.dvr_epg_do_not_record, conflictClickListener) 177 .create() 178 .show(); 179 } 180 181 private static final View.OnFocusChangeListener ON_FOCUS_CHANGED = 182 new View.OnFocusChangeListener() { 183 @Override 184 public void onFocusChange(View view, boolean hasFocus) { 185 if (hasFocus) { 186 ((ProgramItemView) view).mUpdateFocus.run(); 187 } else { 188 Handler handler = view.getHandler(); 189 if (handler != null) { 190 handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus); 191 } 192 } 193 } 194 }; 195 196 private final Runnable mUpdateFocus = new Runnable() { 197 @Override 198 public void run() { 199 refreshDrawableState(); 200 TableEntry entry = mTableEntry; 201 if (entry.isCurrentProgram()) { 202 Drawable background = getBackground(); 203 int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis); 204 setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress); 205 } 206 if (getHandler() != null) { 207 getHandler().postAtTime(this, 208 Utils.ceilTime(SystemClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY)); 209 } 210 } 211 }; 212 213 public ProgramItemView(Context context) { 214 this(context, null); 215 } 216 217 public ProgramItemView(Context context, AttributeSet attrs) { 218 this(context, attrs, 0); 219 } 220 221 public ProgramItemView(Context context, AttributeSet attrs, int defStyle) { 222 super(context, attrs, defStyle); 223 } 224 225 private void initIfNeeded() { 226 if (sVisibleThreshold != 0) { 227 return; 228 } 229 Resources res = getContext().getResources(); 230 231 sVisibleThreshold = res.getDimensionPixelOffset( 232 R.dimen.program_guide_table_item_visible_threshold); 233 234 sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding); 235 236 ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res, 237 R.color.program_guide_table_item_program_title_text_color)); 238 ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res, 239 R.color.program_guide_table_item_grayed_out_program_text_color); 240 ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, 241 R.color.program_guide_table_item_program_episode_title_text_color)); 242 ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, 243 R.color.program_guide_table_item_grayed_out_program_episode_title_text_color)); 244 int programTitleSize = res.getDimensionPixelSize( 245 R.dimen.program_guide_table_item_program_title_font_size); 246 int episodeTitleSize = res.getDimensionPixelSize( 247 R.dimen.program_guide_table_item_program_episode_title_font_size); 248 249 sProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, 250 null); 251 sGrayedOutProgramTitleStyle = new TextAppearanceSpan(null, 0, programTitleSize, 252 grayedOutProgramTitleColor, null); 253 sEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, 254 null); 255 sGrayedOutEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, 256 grayedOutEpisodeTitleColor, null); 257 } 258 259 @Override 260 protected void onFinishInflate() { 261 super.onFinishInflate(); 262 initIfNeeded(); 263 } 264 265 @Override 266 protected int[] onCreateDrawableState(int extraSpace) { 267 if (mTableEntry != null) { 268 int states[] = super.onCreateDrawableState(extraSpace 269 + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length); 270 if (mTableEntry.isCurrentProgram()) { 271 mergeDrawableStates(states, STATE_CURRENT_PROGRAM); 272 } 273 if (mTableEntry.getWidth() > mMaxWidthForRipple) { 274 mergeDrawableStates(states, STATE_TOO_WIDE); 275 } 276 return states; 277 } 278 return super.onCreateDrawableState(extraSpace); 279 } 280 281 public TableEntry getTableEntry() { 282 return mTableEntry; 283 } 284 285 public void onBind(TableEntry entry, ProgramListAdapter adapter) { 286 mTableEntry = entry; 287 setOnClickListener(ON_CLICKED); 288 setOnFocusChangeListener(ON_FOCUS_CHANGED); 289 ProgramManager programManager = adapter.getProgramManager(); 290 291 ViewGroup.LayoutParams layoutParams = getLayoutParams(); 292 layoutParams.width = entry.getWidth(); 293 setLayoutParams(layoutParams); 294 295 String title = entry.program != null ? entry.program.getTitle() : null; 296 String episode = entry.program != null ? 297 entry.program.getEpisodeDisplayTitle(getContext()) : null; 298 299 TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle; 300 TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle; 301 302 if (entry.getWidth() < sVisibleThreshold) { 303 setText(null); 304 } else { 305 if (entry.isGap()) { 306 if (entry.isBlocked()) { 307 title = adapter.getBlockedProgramTitle(); 308 } else { 309 title = adapter.getNoInfoProgramTitle(); 310 } 311 episode = null; 312 } else if (entry.hasGenre(programManager.getSelectedGenreId())) { 313 titleStyle = sProgramTitleStyle; 314 episodeStyle = sEpisodeTitleStyle; 315 } 316 317 SpannableStringBuilder description = new SpannableStringBuilder(); 318 description.append(title); 319 if (!TextUtils.isEmpty(episode)) { 320 description.append('\n'); 321 322 // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for 323 // all lines. This is a non-printing character so it will not change the horizontal 324 // spacing however it will affect the line height. As we ensure the ZWJ has the same 325 // text style as the title it will make sure the line height is consistent. 326 description.append('\u200D'); 327 328 int middle = description.length(); 329 description.append(episode); 330 331 description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 332 description.setSpan(episodeStyle, middle, description.length(), 333 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 334 } else { 335 description.setSpan(titleStyle, 0, description.length(), 336 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 337 } 338 setText(description); 339 } 340 measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 341 mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd(); 342 int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis); 343 int guideStart = GuideUtils.convertMillisToPixel(programManager.getFromUtcMillis()); 344 layoutVisibleArea(guideStart - start); 345 346 // Maximum width for us to use a ripple 347 mMaxWidthForRipple = GuideUtils.convertMillisToPixel( 348 programManager.getFromUtcMillis(), programManager.getToUtcMillis()); 349 } 350 351 /** 352 * Layout title and episode according to visible area. 353 * 354 * Here's the spec. 355 * 1. Don't show text if it's shorter than 48dp. 356 * 2. Try showing whole text in visible area by placing and wrapping text, 357 * but do not wrap text less than 30min. 358 * 3. Episode title is visible only if title isn't multi-line. 359 * 360 * @param offset Offset of the start position from the enclosing view's start position. 361 */ 362 public void layoutVisibleArea(int offset) { 363 int width = mTableEntry.getWidth(); 364 int startPadding = Math.max(0, offset); 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 370 if (startPadding + sItemPadding != getPaddingStart()) { 371 mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent. 372 setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0); 373 mPreventParentRelayout = false; 374 } 375 } 376 377 public void onUnbind() { 378 if (getHandler() != null) { 379 getHandler().removeCallbacks(mUpdateFocus); 380 } 381 382 setTag(null); 383 setOnFocusChangeListener(null); 384 setOnClickListener(null); 385 } 386 387 private static int getProgress(long start, long end) { 388 long currentTime = System.currentTimeMillis(); 389 if (currentTime <= start) { 390 return 0; 391 } else if (currentTime >= end) { 392 return MAX_PROGRESS; 393 } 394 return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start)); 395 } 396 397 private static void setProgress(Drawable drawable, int id, int progress) { 398 if (drawable instanceof StateListDrawable) { 399 StateListDrawable stateDrawable = (StateListDrawable) drawable; 400 for (int i = 0; i < getStateCount(stateDrawable); ++i) { 401 setProgress(getStateDrawable(stateDrawable, i), id, progress); 402 } 403 } else if (drawable instanceof LayerDrawable) { 404 LayerDrawable layerDrawable = (LayerDrawable) drawable; 405 for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) { 406 setProgress(layerDrawable.getDrawable(i), id, progress); 407 if (layerDrawable.getId(i) == id) { 408 layerDrawable.getDrawable(i).setLevel(progress); 409 } 410 } 411 } 412 } 413 414 private static int getStateCount(StateListDrawable stateListDrawable) { 415 try { 416 Object stateCount = StateListDrawable.class.getDeclaredMethod("getStateCount") 417 .invoke(stateListDrawable); 418 return (int) stateCount; 419 } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException 420 |InvocationTargetException e) { 421 Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e); 422 return 0; 423 } 424 } 425 426 private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) { 427 try { 428 Object drawable = StateListDrawable.class 429 .getDeclaredMethod("getStateDrawable", Integer.TYPE) 430 .invoke(stateListDrawable, index); 431 return (Drawable) drawable; 432 } catch (NoSuchMethodException|IllegalAccessException|IllegalArgumentException 433 |InvocationTargetException e) { 434 Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e); 435 return null; 436 } 437 } 438 439 @Override 440 public void requestLayout() { 441 if (mPreventParentRelayout) { 442 // Trivial layout, no need to tell parent. 443 forceLayout(); 444 } else { 445 super.requestLayout(); 446 } 447 } 448} 449