blob: 0fb2ce6e209500e195512e2a14f38e3aa6771d4e [file] [log] [blame]
/*
* 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();
}
}
}