Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e40108e
Fix leak/retry stability issues; give cards a YouTube-style badge and…
claude Jul 15, 2026
e031af6
Fix duration badge rendering as fully opaque instead of semi-transparent
claude Jul 15, 2026
92dbd37
Increase badge transparency/corner radius for visibility
claude Jul 15, 2026
e4789c5
Bump card badge opacity from 50% to 65%
claude Jul 15, 2026
3110587
Give video card title/metadata visual hierarchy; brighten shelf headers
claude Jul 15, 2026
db594ed
Add YouTube-style pill highlight to the focused sidebar item
claude Jul 15, 2026
309d202
Neutralize default Teal scheme's card metadata box color
claude Jul 15, 2026
5b6a7b6
Make sidebar pill hug the icon+label instead of stretching full-width
claude Jul 15, 2026
41f5589
Keep the active sidebar section highlighted after focus moves to content
claude Jul 15, 2026
e62c2c2
Cache repeated findViewById lookups in card/sidebar focus hot paths
claude Jul 15, 2026
6d19b25
More UI hot-path caching and cleanup fixes from background audit
claude Jul 15, 2026
3b05e25
Add embedded on-screen keyboard to the search screen
claude Jul 15, 2026
b6d1e10
Merge branch 'yuliskov:master' into claude/stability-youtube-ui-50mkwh
UmmmAGoodName Jul 22, 2026
d2c9a1c
Merge branch 'yuliskov:master' into claude/stability-youtube-ui-50mkwh
UmmmAGoodName Jul 23, 2026
e0f1789
Restyle search box and suggestion chips to match YouTube
claude Jul 23, 2026
5a4900f
Merge remote-tracking branch 'origin/claude/stability-youtube-ui-50mk…
claude Jul 23, 2026
2bf0c3e
Reorder default sidebar to match YouTube TV's May 2026 redesign
claude Jul 23, 2026
8e7db48
Center transport controls, add floating pill to secondary controls
claude Jul 23, 2026
fef32f1
Move video title to the top-left corner of the player, YouTube style
claude Jul 23, 2026
9076b03
Give the play/pause button a persistent white circle, YouTube style
claude Jul 23, 2026
ce616b9
Fix SponsorBlock markers not showing on the player seekbar
claude Jul 23, 2026
bd7a77d
Show the search icon inline in the search box, YouTube style
claude Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@
public class VideoLoaderController extends BasePlayerController {
private static final String TAG = VideoLoaderController.class.getSimpleName();
private static final int MIN_SHUFFLE_SIZE = 30;
// Cap automatic engine restarts so a persistent error (e.g. no network, wrong clock)
// can't loop forever, hammering the player once a second.
private static final int MAX_RESTART_ENGINE_ATTEMPTS = 8;
private static final int MAX_RESTART_ENGINE_DELAY_MS = 16_000;
private static final long RESTART_ENGINE_RESET_INTERVAL_MS = 60_000;
private final Playlist mPlaylist;
private Video mPendingVideo;
private SuggestionsController mSuggestionsController;
private ErrorFixerController mErrorFixerController;
private long mSleepTimerStartMs;
private Disposable mFormatInfoAction;
private int mRestartEngineAttempts;
private long mLastRestartEngineMs;
private final Runnable mReloadVideo = () -> {
getMainController().onNewVideo(getVideo());
};
Expand Down Expand Up @@ -390,8 +397,24 @@ private void loadNextVideo(int delayMs) {

private void restartEngine(int delayMs) {
if (getPlayer() != null) {
Log.d(TAG, "Restarting the engine...");
Utils.postDelayed(mRestartEngine, delayMs);
long nowMs = System.currentTimeMillis();
if (nowMs - mLastRestartEngineMs > RESTART_ENGINE_RESET_INTERVAL_MS) {
// Errors are spaced far apart. Treat this as a fresh problem instead of
// carrying over the attempt count from an earlier, unrelated hiccup.
mRestartEngineAttempts = 0;
}
mLastRestartEngineMs = nowMs;
mRestartEngineAttempts++;

if (mRestartEngineAttempts > MAX_RESTART_ENGINE_ATTEMPTS) {
Log.e(TAG, "Too many consecutive engine restarts. Giving up to avoid an infinite restart loop.");
return;
}

int backoffDelayMs = (int) Math.min((long) delayMs << (mRestartEngineAttempts - 1), MAX_RESTART_ENGINE_DELAY_MS);

Log.d(TAG, "Restarting the engine... Attempt %s, delay %sms", mRestartEngineAttempts, backoffDelayMs);
Utils.postDelayed(mRestartEngine, backoffDelayMs);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ private void extractChannelId(Video item, OnChannelId callback) {

public void onSearchSettingsClicked() {
Observable<List<MediaGroup>> sorting = getContentService().getChannelSortingOptionsObserve(getChannelId());
Disposable result = sorting.subscribe(
mUpdateAction = sorting.subscribe(
items -> {
AppDialogPresenter dialogPresenter = AppDialogPresenter.instance(getContext());
List<OptionItem> options = new ArrayList<>();
Expand All @@ -365,7 +365,7 @@ public void onSearchSettingsClicked() {
options.add(UiOptionItem.from(group.getTitle(), item -> {
//dialogPresenter.closeDialog();
Observable<MediaGroup> continuation = getContentService().continueGroupObserve(group);
Disposable result2 = continuation.subscribe(mediaGroup -> {
mUpdateAction = continuation.subscribe(mediaGroup -> {
if (getView() == null) {
return;
}
Expand All @@ -390,7 +390,7 @@ public void onSearchSettingsClicked() {

public boolean onSearchSubmit(String query) {
Observable<MediaGroup> search = getContentService().getChannelSearchObserve(getChannelId(), query);
Disposable result = search.subscribe(
mUpdateAction = search.subscribe(
items -> {
if (getView() == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,16 @@ private int getSectionId(Video item) {
}

private void initSections() {
mDefaultSections.put(R.string.header_notifications, MediaGroup.TYPE_NOTIFICATIONS);
// Order matches YouTube TV's May 2026 sidebar redesign: Home at the top, then
// Subscriptions/Library-equivalent items (history, playlists, your videos, channels)
// promoted right below it, then content categories, then Settings pinned at the bottom.
// (Notifications/blocked channels/queue are hidden by default anyway - see initPinnedItems().)
mDefaultSections.put(R.string.header_home, MediaGroup.TYPE_HOME);
mDefaultSections.put(R.string.header_subscriptions, MediaGroup.TYPE_SUBSCRIPTIONS);
mDefaultSections.put(R.string.header_history, MediaGroup.TYPE_HISTORY);
mDefaultSections.put(R.string.header_playlists, MediaGroup.TYPE_USER_PLAYLISTS);
mDefaultSections.put(R.string.my_videos, MediaGroup.TYPE_MY_VIDEOS);
mDefaultSections.put(R.string.header_channels, MediaGroup.TYPE_CHANNEL_UPLOADS);
mDefaultSections.put(R.string.header_shorts, MediaGroup.TYPE_SHORTS);
mDefaultSections.put(R.string.header_trending, MediaGroup.TYPE_TRENDING);
mDefaultSections.put(R.string.header_kids_home, MediaGroup.TYPE_KIDS_HOME);
Expand All @@ -246,12 +254,8 @@ private void initSections() {
mDefaultSections.put(R.string.header_gaming, MediaGroup.TYPE_GAMING);
mDefaultSections.put(R.string.header_news, MediaGroup.TYPE_NEWS);
mDefaultSections.put(R.string.header_music, MediaGroup.TYPE_MUSIC);
mDefaultSections.put(R.string.header_channels, MediaGroup.TYPE_CHANNEL_UPLOADS);
mDefaultSections.put(R.string.header_subscriptions, MediaGroup.TYPE_SUBSCRIPTIONS);
mDefaultSections.put(R.string.header_history, MediaGroup.TYPE_HISTORY);
mDefaultSections.put(R.string.header_notifications, MediaGroup.TYPE_NOTIFICATIONS);
mDefaultSections.put(R.string.header_blocked_channels, MediaGroup.TYPE_BLOCKED_CHANNELS);
mDefaultSections.put(R.string.header_playlists, MediaGroup.TYPE_USER_PLAYLISTS);
mDefaultSections.put(R.string.my_videos, MediaGroup.TYPE_MY_VIDEOS);
mDefaultSections.put(R.string.playback_queue_category_title, MediaGroup.TYPE_PLAYBACK_QUEUE);
mDefaultSections.put(R.string.header_settings, MediaGroup.TYPE_SETTINGS);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ public void remove(VideoGroup group) {
}

public void removeAuthor(VideoGroup group) {
if (group.isEmpty()) {
return;
}

String author = group.getVideos().get(0).getAuthor(); // assume same author
List<Video> result = Helpers.filter(mVideoItems, video -> Helpers.equals(author, video.getAuthor()));
if (result != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ public class ChannelCardPresenter extends LongClickPresenter {
private int mWidth;
private int mHeight;

// Caches the child views found in onCreateViewHolder so onBindViewHolder/onUnbindViewHolder
// (which run on every card recycle during scrolling) don't re-run findViewById() each time.
private static class ChannelViewHolder extends ViewHolder {
final View wrapper;
final TextView title;
final ImageView image;

ChannelViewHolder(View view, View wrapper, TextView title, ImageView image) {
super(view);
this.wrapper = wrapper;
this.title = title;
this.image = image;
}
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
Context context = parent.getContext();
Expand Down Expand Up @@ -88,42 +103,47 @@ public ViewHolder onCreateViewHolder(ViewGroup parent) {
}
});

return new ViewHolder(container);
View wrapper = container.findViewById(R.id.channel_card_wrapper);
ImageView imageView = container.findViewById(R.id.channel_image);

return new ChannelViewHolder(container, wrapper, textView, imageView);
}

@Override
public void onBindViewHolder(ViewHolder viewHolder, Object item) {
super.onBindViewHolder(viewHolder, item);

Context context = viewHolder.view.getContext();
ChannelViewHolder holder = (ChannelViewHolder) viewHolder;
Context context = holder.view.getContext();
Video video = (Video) item;

ViewUtil.setDimensions(viewHolder.view.findViewById(R.id.channel_card_wrapper), mWidth, -1); // don't do auto height
ViewUtil.setDimensions(holder.wrapper, mWidth, -1); // don't do auto height

TextView textView = viewHolder.view.findViewById(R.id.channel_title);
textView.setText(video.getTitle());
holder.title.setText(video.getTitle());

// We should setup props each time because object may be reused by the underlying RecyclerView
textView.setBackgroundColor(video.hasNewContent ? mNewContentBackgroundColor : mDefaultBackgroundColor);
textView.setTag(R.id.channel_new_content, video.hasNewContent ? true : null);

holder.title.setBackgroundColor(video.hasNewContent ? mNewContentBackgroundColor : mDefaultBackgroundColor);
holder.title.setTag(R.id.channel_new_content, video.hasNewContent ? true : null);

ImageView imageView = viewHolder.view.findViewById(R.id.channel_image);
imageView.setVisibility(View.VISIBLE);
holder.image.setVisibility(View.VISIBLE);

Glide.with(context)
.load(video.cardImageUrl)
.apply(ViewUtil.glideOptions())
.listener(mErrorListener)
//.error(R.drawable.card_placeholder) // R.color.lb_grey
.into(imageView);
.into(holder.image);
}

@Override
public void onUnbindViewHolder(ViewHolder viewHolder) {
// Remove references to images so that the garbage collector can free up memory.
ImageView imageView = viewHolder.view.findViewById(R.id.channel_image);
imageView.setImageDrawable(null);
ChannelViewHolder holder = (ChannelViewHolder) viewHolder;

// Remove references to images so that the garbage collector can free up memory,
// and cancel any in-flight Glide request so a stale thumbnail can't flash into
// this view when it gets recycled for a different channel.
holder.image.setImageDrawable(null);
Glide.with(holder.view.getContext().getApplicationContext()).clear(holder.image);
}

private void updateDimensions(Context context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package com.liskovsoft.smartyoutubetv2.tv.presenter;

import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.ColorUtils;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.PageRow;
Expand All @@ -21,6 +24,8 @@
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.liskovsoft.sharedutils.mylogger.Log;
import com.liskovsoft.smartyoutubetv2.common.app.models.data.BrowseSection;
import com.liskovsoft.smartyoutubetv2.common.app.presenters.BrowsePresenter;
import com.liskovsoft.smartyoutubetv2.tv.R;
import com.liskovsoft.smartyoutubetv2.tv.util.ViewUtil;

Expand All @@ -30,6 +35,24 @@ public class IconHeaderItemPresenter extends RowHeaderPresenter {
private final int mResId;
private final String mIconUrl;
private Drawable mDefaultIcon;
private int mUnselectedTextColor;
private int mSelectedTextColor;

private static class IconViewHolder extends ViewHolder {
final ImageView icon;
final TextView label;
final GradientDrawable pill;
// True while this row is the currently open section (e.g. "Home"), independent
// of whether the sidebar itself currently has keyboard/D-pad focus.
boolean isActive;

IconViewHolder(View view, ImageView icon, TextView label, GradientDrawable pill) {
super(view);
this.icon = icon;
this.label = label;
this.pill = pill;
}
}

public IconHeaderItemPresenter(int resId, String iconUrl) {
mResId = resId;
Expand All @@ -43,11 +66,25 @@ public ViewHolder onCreateViewHolder(ViewGroup viewGroup) {
LayoutInflater inflater = (LayoutInflater) viewGroup.getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mDefaultIcon = new ColorDrawable(ContextCompat.getColor(viewGroup.getContext(), R.color.lb_grey));
mUnselectedTextColor = ContextCompat.getColor(viewGroup.getContext(), R.color.sidebar_item_text);
mSelectedTextColor = ContextCompat.getColor(viewGroup.getContext(), R.color.sidebar_item_selected_text);

View view = inflater.inflate(R.layout.icon_header_item, null);
view.setAlpha(mUnselectedAlpha); // Initialize icons to be at half-opacity.

return new ViewHolder(view);
ImageView icon = view.findViewById(R.id.header_icon);
TextView label = view.findViewById(R.id.header_label);
View pillView = view.findViewById(R.id.header_pill);

// Rounded "pill" highlight behind the currently focused sidebar item, YouTube style.
// Starts fully transparent; onSelectLevelChanged() fades it in.
GradientDrawable pill = pillView != null && pillView.getBackground() instanceof GradientDrawable
? (GradientDrawable) pillView.getBackground().mutate() : null;
if (pill != null) {
pill.setAlpha(0);
}

return new IconViewHolder(view, icon, label, pill);
}

@Override
Expand All @@ -63,39 +100,102 @@ public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
View rootView = viewHolder.view;
rootView.setFocusable(true);

ImageView iconView = rootView.findViewById(R.id.header_icon);
// IconViewHolder already caches these from onCreateViewHolder; avoid re-querying
// findViewById on every bind (this runs on every scroll/rebind of the sidebar).
boolean isIconHolder = viewHolder instanceof IconViewHolder;
IconViewHolder iconHolder = isIconHolder ? (IconViewHolder) viewHolder : null;
ImageView iconView = isIconHolder ? iconHolder.icon : rootView.findViewById(R.id.header_icon);
TextView label = isIconHolder ? iconHolder.label : rootView.findViewById(R.id.header_label);

if (iconView != null) {
if (mIconUrl != null) {
// Remote icon (e.g. a pinned channel's own avatar): keep its real colors, don't tint it.
iconView.clearColorFilter();
Glide.with(rootView.getContext())
.load(mIconUrl)
.apply(ViewUtil.glideOptions().error(mDefaultIcon))
.listener(mErrorListener)
.into(iconView);

//ViewUtil.makeMonochrome(iconView);
} else {
Drawable icon = mResId > 0 ? ContextCompat.getDrawable(rootView.getContext(), mResId) : mDefaultIcon;
iconView.setImageDrawable(icon);
}
}

TextView label = rootView.findViewById(R.id.header_label);
if (label != null) {
label.setText(headerItem.getName());
}

if (iconHolder != null) {
iconHolder.isActive = isActiveSection(headerItem, rootView.getContext());
// Rebinding (e.g. after switching sections) doesn't go through
// onSelectLevelChanged, so re-apply the highlight here too.
applyHighlight(iconHolder, Math.max(iconHolder.getSelectLevel(), iconHolder.isActive ? 1f : 0f));
}
}

@Override
public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) {
// NOP
// Cancel any in-flight remote-icon load so it can't land on this view after
// it's been rebound to a different section.
if (viewHolder instanceof IconViewHolder) {
ImageView icon = ((IconViewHolder) viewHolder).icon;

if (icon != null) {
Glide.with(icon.getContext().getApplicationContext()).clear(icon);
}
}
}

// TODO: This is a temporary fix. Remove me when leanback onCreateViewHolder no longer sets the
// mUnselectAlpha, and also assumes the xml inflation will return a RowHeaderView.
@Override
protected void onSelectLevelChanged(RowHeaderPresenter.ViewHolder holder) {
holder.view.setAlpha(mUnselectedAlpha + holder.getSelectLevel() *
(1.0f - mUnselectedAlpha));
float selectLevel = holder.getSelectLevel();

holder.view.setAlpha(mUnselectedAlpha + selectLevel * (1.0f - mUnselectedAlpha));

if (!(holder instanceof IconViewHolder)) {
return;
}

IconViewHolder iconHolder = (IconViewHolder) holder;

// Keep the currently active section highlighted even as focus moves away from it,
// e.g. into the video grid, like the official app.
applyHighlight(iconHolder, Math.max(selectLevel, iconHolder.isActive ? 1f : 0f));
}

private void applyHighlight(IconViewHolder holder, float level) {
if (holder.pill != null) {
holder.pill.setAlpha(Math.round(255 * level));
}

int textColor = ColorUtils.blendARGB(mUnselectedTextColor, mSelectedTextColor, level);

if (holder.label != null) {
holder.label.setTextColor(textColor);
}

// Only tint the built-in monochrome icons. A remote icon (mIconUrl != null, e.g. a
// pinned channel's avatar) keeps its real colors and shouldn't be flattened to a silhouette.
if (holder.icon != null) {
if (mIconUrl == null) {
holder.icon.setColorFilter(textColor, PorterDuff.Mode.SRC_IN);
} else {
holder.icon.clearColorFilter();
}
}
}

private boolean isActiveSection(HeaderItem headerItem, Context context) {
if (headerItem == null) {
return false;
}

BrowseSection currentSection = BrowsePresenter.instance(context).getCurrentSection();

return currentSection != null && currentSection.getId() == (int) headerItem.getId();
}

private final RequestListener<Drawable> mErrorListener = new RequestListener<Drawable>() {
Expand Down
Loading