| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tv.guide; |
| |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.content.res.ColorStateList; |
| import android.content.res.Resources; |
| import android.graphics.drawable.Drawable; |
| import android.graphics.drawable.LayerDrawable; |
| import android.graphics.drawable.StateListDrawable; |
| import android.os.Build; |
| import android.os.Handler; |
| import android.text.SpannableStringBuilder; |
| import android.text.Spanned; |
| import android.text.TextUtils; |
| import android.text.style.TextAppearanceSpan; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.widget.TextView; |
| import android.widget.Toast; |
| |
| import com.android.tv.MainActivity; |
| import com.android.tv.R; |
| import com.android.tv.TvSingletons; |
| import com.android.tv.analytics.Tracker; |
| import com.android.tv.common.feature.CommonFeatures; |
| import com.android.tv.common.flags.DvrFlags; |
| import com.android.tv.common.util.Clock; |
| import com.android.tv.data.ChannelDataManager; |
| import com.android.tv.data.api.Channel; |
| import com.android.tv.data.api.Program; |
| import com.android.tv.dvr.DvrManager; |
| import com.android.tv.dvr.data.ScheduledRecording; |
| import com.android.tv.dvr.ui.DvrUiHelper; |
| import com.android.tv.guide.ProgramManager.TableEntry; |
| import com.android.tv.util.ToastUtils; |
| import com.android.tv.util.Utils; |
| |
| import dagger.android.HasAndroidInjector; |
| |
| import java.lang.reflect.InvocationTargetException; |
| import java.util.concurrent.TimeUnit; |
| |
| import javax.inject.Inject; |
| |
| public class ProgramItemView extends TextView { |
| private static final String TAG = "ProgramItemView"; |
| |
| private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1); |
| private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE |
| |
| // State indicating the focused program is the current program |
| private static final int[] STATE_CURRENT_PROGRAM = {R.attr.state_current_program}; |
| |
| // Workaround state in order to not use too much texture memory for RippleDrawable |
| private static final int[] STATE_TOO_WIDE = {R.attr.state_program_too_wide}; |
| |
| private static int sVisibleThreshold; |
| private static int sItemPadding; |
| private static int sCompoundDrawablePadding; |
| private static TextAppearanceSpan sProgramTitleStyle; |
| private static TextAppearanceSpan sGrayedOutProgramTitleStyle; |
| private static TextAppearanceSpan sEpisodeTitleStyle; |
| private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; |
| |
| private final DvrManager mDvrManager; |
| @Inject Clock mClock; |
| @Inject ChannelDataManager mChannelDataManager; |
| @Inject DvrFlags mDvrFlags; |
| private ProgramGuide mProgramGuide; |
| private TableEntry mTableEntry; |
| private int mMaxWidthForRipple; |
| private int mTextWidth; |
| |
| // If set this flag disables requests to re-layout the parent view as a result of changing |
| // this view, improving performance. This also prevents the parent view to lose child focus |
| // as a result of the re-layout (see b/21378855). |
| private boolean mPreventParentRelayout; |
| |
| private static final View.OnClickListener ON_CLICKED = |
| new View.OnClickListener() { |
| @Override |
| public void onClick(final View view) { |
| TableEntry entry = ((ProgramItemView) view).mTableEntry; |
| Clock clock = ((ProgramItemView) view).mClock; |
| DvrFlags dvrFlags = ((ProgramItemView) view).mDvrFlags; |
| if (entry == null) { |
| // do nothing |
| return; |
| } |
| TvSingletons singletons = TvSingletons.getSingletons(view.getContext()); |
| Tracker tracker = singletons.getTracker(); |
| tracker.sendEpgItemClicked(); |
| final MainActivity tvActivity = (MainActivity) view.getContext(); |
| final Channel channel = |
| tvActivity.getChannelDataManager().getChannel(entry.channelId); |
| if (entry.isCurrentProgram()) { |
| view.postDelayed( |
| () -> { |
| tvActivity.tuneToChannel(channel); |
| tvActivity.hideOverlaysForTune(); |
| }, |
| entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple |
| ? 0 |
| : view.getResources() |
| .getInteger( |
| R.integer |
| .program_guide_ripple_anim_duration)); |
| } else if (entry.program != null |
| && CommonFeatures.DVR.isEnabled(view.getContext())) { |
| DvrManager dvrManager = singletons.getDvrManager(); |
| if (entry.entryStartUtcMillis > clock.currentTimeMillis() |
| && dvrManager.isProgramRecordable(entry.program)) { |
| if (entry.scheduledRecording == null) { |
| if (!entry.program.isEpisodic() && |
| dvrFlags.startEarlyEndLateEnabled()) { |
| DvrUiHelper.startRecordingSettingsActivity(view.getContext(), |
| entry.program); |
| } else { |
| DvrUiHelper.checkStorageStatusAndShowErrorMessage( |
| tvActivity, |
| channel.getInputId(), |
| () -> |
| DvrUiHelper.requestRecordingFutureProgram( |
| tvActivity, entry.program, false)); |
| } |
| } else { |
| dvrManager.removeScheduledRecording(entry.scheduledRecording); |
| String msg = |
| view.getResources() |
| .getString( |
| R.string.dvr_schedules_deletion_info, |
| entry.program.getTitle()); |
| ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT); |
| } |
| } else { |
| ToastUtils.show( |
| view.getContext(), |
| view.getResources() |
| .getString(R.string.dvr_msg_cannot_record_program), |
| Toast.LENGTH_SHORT); |
| } |
| } |
| } |
| }; |
| |
| private static final View.OnFocusChangeListener ON_FOCUS_CHANGED = |
| new View.OnFocusChangeListener() { |
| @Override |
| public void onFocusChange(View view, boolean hasFocus) { |
| if (hasFocus) { |
| ((ProgramItemView) view).mUpdateFocus.run(); |
| } else { |
| Handler handler = view.getHandler(); |
| if (handler != null) { |
| handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus); |
| } |
| } |
| } |
| }; |
| |
| private final Runnable mUpdateFocus = |
| new Runnable() { |
| @Override |
| public void run() { |
| refreshDrawableState(); |
| TableEntry entry = mTableEntry; |
| if (entry == null) { |
| // do nothing |
| return; |
| } |
| if (entry.isCurrentProgram()) { |
| Drawable background = getBackground(); |
| if (!mProgramGuide.isActive() || mProgramGuide.isRunningAnimation()) { |
| // If program guide is not active or is during showing/hiding, |
| // the animation is unnecessary, skip it. |
| background.jumpToCurrentState(); |
| } |
| int progress = |
| getProgress( |
| mClock, entry.entryStartUtcMillis, entry.entryEndUtcMillis); |
| setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress); |
| } |
| if (getHandler() != null) { |
| getHandler() |
| .postAtTime( |
| this, |
| Utils.ceilTime( |
| mClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY)); |
| } |
| } |
| }; |
| |
| public ProgramItemView(Context context) { |
| this(context, null); |
| } |
| |
| public ProgramItemView(Context context, AttributeSet attrs) { |
| this(context, attrs, 0); |
| } |
| |
| public ProgramItemView(Context context, AttributeSet attrs, int defStyle) { |
| super(context, attrs, defStyle); |
| ((HasAndroidInjector) context).androidInjector().inject(this); |
| setOnClickListener(ON_CLICKED); |
| setOnFocusChangeListener(ON_FOCUS_CHANGED); |
| TvSingletons singletons = TvSingletons.getSingletons(getContext()); |
| mDvrManager = singletons.getDvrManager(); |
| } |
| |
| private void initIfNeeded() { |
| if (sVisibleThreshold != 0) { |
| return; |
| } |
| Resources res = getContext().getResources(); |
| |
| sVisibleThreshold = |
| res.getDimensionPixelOffset(R.dimen.program_guide_table_item_visible_threshold); |
| |
| sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding); |
| sCompoundDrawablePadding = |
| res.getDimensionPixelOffset( |
| R.dimen.program_guide_table_item_compound_drawable_padding); |
| |
| ColorStateList programTitleColor = |
| ColorStateList.valueOf( |
| res.getColor( |
| R.color.program_guide_table_item_program_title_text_color, null)); |
| ColorStateList grayedOutProgramTitleColor = |
| res.getColorStateList( |
| R.color.program_guide_table_item_grayed_out_program_text_color, null); |
| ColorStateList episodeTitleColor = |
| ColorStateList.valueOf( |
| res.getColor( |
| R.color.program_guide_table_item_program_episode_title_text_color, |
| null)); |
| ColorStateList grayedOutEpisodeTitleColor = |
| ColorStateList.valueOf( |
| res.getColor( |
| R.color |
| .program_guide_table_item_grayed_out_program_episode_title_text_color, |
| null)); |
| int programTitleSize = |
| res.getDimensionPixelSize(R.dimen.program_guide_table_item_program_title_font_size); |
| int episodeTitleSize = |
| res.getDimensionPixelSize( |
| R.dimen.program_guide_table_item_program_episode_title_font_size); |
| |
| sProgramTitleStyle = |
| new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, null); |
| sGrayedOutProgramTitleStyle = |
| new TextAppearanceSpan(null, 0, programTitleSize, grayedOutProgramTitleColor, null); |
| sEpisodeTitleStyle = |
| new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, null); |
| sGrayedOutEpisodeTitleStyle = |
| new TextAppearanceSpan(null, 0, episodeTitleSize, grayedOutEpisodeTitleColor, null); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| initIfNeeded(); |
| } |
| |
| @Override |
| protected int[] onCreateDrawableState(int extraSpace) { |
| if (mTableEntry != null) { |
| int[] states = |
| super.onCreateDrawableState( |
| extraSpace + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length); |
| if (mTableEntry.isCurrentProgram()) { |
| mergeDrawableStates(states, STATE_CURRENT_PROGRAM); |
| } |
| if (mTableEntry.getWidth() > mMaxWidthForRipple) { |
| mergeDrawableStates(states, STATE_TOO_WIDE); |
| } |
| return states; |
| } |
| return super.onCreateDrawableState(extraSpace); |
| } |
| |
| public TableEntry getTableEntry() { |
| return mTableEntry; |
| } |
| |
| @SuppressLint("SwitchIntDef") |
| public void setValues( |
| ProgramGuide programGuide, |
| TableEntry entry, |
| int selectedGenreId, |
| long fromUtcMillis, |
| long toUtcMillis, |
| String gapTitle) { |
| mProgramGuide = programGuide; |
| mTableEntry = entry; |
| |
| ViewGroup.LayoutParams layoutParams = getLayoutParams(); |
| if (layoutParams != null) { |
| // There is no layoutParams in the tests so we skip this |
| layoutParams.width = entry.getWidth(); |
| setLayoutParams(layoutParams); |
| } |
| String title = mTableEntry.program != null ? mTableEntry.program.getTitle() : null; |
| if (mTableEntry.isGap()) { |
| title = gapTitle; |
| } |
| if (TextUtils.isEmpty(title)) { |
| title = getResources().getString(R.string.program_title_for_no_information); |
| } |
| updateText(selectedGenreId, title); |
| updateIcons(); |
| updateContentDescription(title); |
| measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); |
| mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd(); |
| // Maximum width for us to use a ripple |
| mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis); |
| } |
| |
| private boolean isEntryWideEnough() { |
| return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold; |
| } |
| |
| private void updateText(int selectedGenreId, String title) { |
| if (!isEntryWideEnough()) { |
| setText(null); |
| return; |
| } |
| |
| String episode = |
| mTableEntry.program != null |
| ? mTableEntry.program.getEpisodeDisplayTitle(getContext()) |
| : null; |
| |
| TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle; |
| TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle; |
| if (mTableEntry.isGap()) { |
| |
| episode = null; |
| } else if (mTableEntry.hasGenre(selectedGenreId)) { |
| titleStyle = sProgramTitleStyle; |
| episodeStyle = sEpisodeTitleStyle; |
| } |
| SpannableStringBuilder description = new SpannableStringBuilder(); |
| description.append(title); |
| if (!TextUtils.isEmpty(episode)) { |
| description.append('\n'); |
| |
| // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for |
| // all lines. This is a non-printing character so it will not change the horizontal |
| // spacing however it will affect the line height. As we ensure the ZWJ has the same |
| // text style as the title it will make sure the line height is consistent. |
| description.append('\u200D'); |
| |
| int middle = description.length(); |
| description.append(episode); |
| |
| description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| description.setSpan( |
| episodeStyle, middle, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } else { |
| description.setSpan( |
| titleStyle, 0, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| setText(description); |
| } |
| |
| private void updateIcons() { |
| // Sets recording icons if needed. |
| int iconResId = 0; |
| if (isEntryWideEnough() && mTableEntry.scheduledRecording != null) { |
| if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) { |
| iconResId = R.drawable.quantum_ic_warning_white_18; |
| } else { |
| switch (mTableEntry.scheduledRecording.getState()) { |
| case ScheduledRecording.STATE_RECORDING_NOT_STARTED: |
| iconResId = R.drawable.ic_scheduled_recording; |
| break; |
| case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: |
| iconResId = R.drawable.ic_recording_program; |
| break; |
| default: |
| // leave the iconResId=0 |
| } |
| } |
| } |
| setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0); |
| setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0); |
| } |
| |
| private void updateContentDescription(String title) { |
| // The content description includes extra information that is displayed on the detail view |
| Resources resources = getResources(); |
| String description = title; |
| // TODO(b/73282818): only say channel name when the row changes |
| Channel channel = mChannelDataManager.getChannel(mTableEntry.channelId); |
| if (channel != null) { |
| description = channel.getDisplayNumber() + " " + description; |
| } |
| Program program = mTableEntry.program; |
| if (program != null) { |
| description += " " + program.getDurationString(getContext()); |
| String episodeDescription = program.getEpisodeContentDescription(getContext()); |
| if (!TextUtils.isEmpty(episodeDescription)) { |
| description += " " + episodeDescription; |
| } |
| } else { |
| description += |
| " " |
| + Utils.getDurationString( |
| getContext(), |
| mClock, |
| mTableEntry.entryStartUtcMillis, |
| mTableEntry.entryEndUtcMillis, |
| true); |
| } |
| if (mTableEntry.scheduledRecording != null) { |
| if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) { |
| description += |
| " " + resources.getString(R.string.dvr_epg_program_recording_conflict); |
| } else { |
| switch (mTableEntry.scheduledRecording.getState()) { |
| case ScheduledRecording.STATE_RECORDING_NOT_STARTED: |
| description += |
| " " |
| + resources.getString( |
| R.string.dvr_epg_program_recording_scheduled); |
| break; |
| case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: |
| description += |
| " " |
| + resources.getString( |
| R.string.dvr_epg_program_recording_in_progress); |
| break; |
| default: |
| // do nothing |
| } |
| } |
| } |
| if (mTableEntry.isBlocked()) { |
| description += " " + resources.getString(R.string.program_guide_content_locked); |
| } else if (program != null) { |
| String programDescription = program.getDescription(); |
| if (!TextUtils.isEmpty(programDescription)) { |
| description += " " + programDescription; |
| } |
| } |
| setContentDescription(description); |
| } |
| |
| /** Update programItemView to handle alignments of text. */ |
| public void updateVisibleArea() { |
| View parentView = ((View) getParent()); |
| if (parentView == null) { |
| return; |
| } |
| if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) { |
| layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight()); |
| } else { |
| layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft()); |
| } |
| } |
| |
| /** |
| * Layout title and episode according to visible area. |
| * |
| * <p>Here's the spec. 1. Don't show text if it's shorter than 48dp. 2. Try showing whole text |
| * in visible area by placing and wrapping text, but do not wrap text less than 30min. 3. |
| * Episode title is visible only if title isn't multi-line. |
| * |
| * @param startOffset Offset of the start position from the enclosing view's start position. |
| * @param endOffset Offset of the end position from the enclosing view's end position. |
| */ |
| private void layoutVisibleArea(int startOffset, int endOffset) { |
| int width = mTableEntry.getWidth(); |
| int startPadding = Math.max(0, startOffset); |
| int endPadding = Math.max(0, endOffset); |
| int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding); |
| if (startPadding > 0 && width - startPadding < minWidth) { |
| startPadding = Math.max(0, width - minWidth); |
| } |
| if (endPadding > 0 && width - endPadding < minWidth) { |
| endPadding = Math.max(0, width - minWidth); |
| } |
| |
| if (startPadding + sItemPadding != getPaddingStart() |
| || endPadding + sItemPadding != getPaddingEnd()) { |
| mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent. |
| setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0); |
| mPreventParentRelayout = false; |
| } |
| } |
| |
| public void clearValues() { |
| if (getHandler() != null) { |
| getHandler().removeCallbacks(mUpdateFocus); |
| } |
| |
| setTag(null); |
| mProgramGuide = null; |
| mTableEntry = null; |
| } |
| |
| private static int getProgress(Clock clock, long start, long end) { |
| long currentTime = clock.currentTimeMillis(); |
| if (currentTime <= start) { |
| return 0; |
| } else if (currentTime >= end) { |
| return MAX_PROGRESS; |
| } |
| return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start)); |
| } |
| |
| private static void setProgress(Drawable drawable, int id, int progress) { |
| if (drawable instanceof StateListDrawable) { |
| StateListDrawable stateDrawable = (StateListDrawable) drawable; |
| for (int i = 0; i < getStateCount(stateDrawable); ++i) { |
| setProgress(getStateDrawable(stateDrawable, i), id, progress); |
| } |
| } else if (drawable instanceof LayerDrawable) { |
| LayerDrawable layerDrawable = (LayerDrawable) drawable; |
| for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) { |
| setProgress(layerDrawable.getDrawable(i), id, progress); |
| if (layerDrawable.getId(i) == id) { |
| layerDrawable.getDrawable(i).setLevel(progress); |
| } |
| } |
| } |
| } |
| |
| private static int getStateCount(StateListDrawable stateListDrawable) { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| return stateListDrawable.getStateCount(); |
| } |
| try { |
| Object stateCount = |
| StateListDrawable.class |
| .getDeclaredMethod("getStateCount") |
| .invoke(stateListDrawable); |
| return (int) stateCount; |
| } catch (NoSuchMethodException |
| | IllegalAccessException |
| | IllegalArgumentException |
| | InvocationTargetException e) { |
| Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e); |
| return 0; |
| } |
| } |
| |
| private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| return stateListDrawable.getStateDrawable(index); |
| } |
| try { |
| Object drawable = |
| StateListDrawable.class |
| .getDeclaredMethod("getStateDrawable", Integer.TYPE) |
| .invoke(stateListDrawable, index); |
| return (Drawable) drawable; |
| } catch (NoSuchMethodException |
| | IllegalAccessException |
| | IllegalArgumentException |
| | InvocationTargetException e) { |
| Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e); |
| return null; |
| } |
| } |
| |
| @Override |
| public void requestLayout() { |
| if (mPreventParentRelayout) { |
| // Trivial layout, no need to tell parent. |
| forceLayout(); |
| } else { |
| super.requestLayout(); |
| } |
| } |
| } |