/* * mygram-droid: Android TV client for Foldergram. * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 as published * by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * for more details. */ package mygram.droid; import android.app.Activity; import android.content.Intent; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.Space; import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ThreadLocalRandom; /** Top bar, rotating blurred background, and the Home, Liked and Folders tabs. */ public final class MainActivity extends Activity { private static final int HOME = 0, LIKED = 1, FOLDERS = 2; private static final ViewGroup.LayoutParams FILL = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); private final Handler handler = new Handler(Looper.getMainLooper()); private final Runnable rotate = this::rotateBackground; private FrameLayout content; /** Two layers that swap, so a new background fades in over the old one. */ private final ImageView[] layers = new ImageView[2]; private int front; private List backgrounds = new ArrayList<>(); private RecyclerView homeList; private HomeRows homeRows; private int tab = HOME; private boolean ready, resumed, homeLoading, connecting; private LinearLayout homeButton, likedButton, foldersButton; @Override protected void onCreate(Bundle state) { super.onCreate(state); if (Prefs.server() == null) { startActivity(new Intent(this, SetupActivity.class)); finish(); return; } FrameLayout root = new FrameLayout(this); root.setBackgroundColor(Color.BLACK); FrameLayout layerBox = new FrameLayout(this); // bringToFront() stays inside it for (int i = 0; i < 2; i++) { layers[i] = new ImageView(this); layers[i].setScaleType(ImageView.ScaleType.CENTER_CROP); Ui.blur(layers[i], 60); layerBox.addView(layers[i], FILL); } root.addView(layerBox, FILL); View dim = new View(this); dim.setBackgroundColor(0xAA000000); root.addView(dim, FILL); LinearLayout column = new LinearLayout(this); column.setOrientation(LinearLayout.VERTICAL); column.addView(topBar()); content = new FrameLayout(this); column.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); root.addView(column, FILL); setContentView(root); // connect() runs from onResume once the splash has let the user in. } /** [logo] [Reels] [Liked] [Folders] [Settings]; the current view is marked. */ private View topBar() { LinearLayout bar = new LinearLayout(this); bar.setGravity(Gravity.CENTER_VERTICAL); int p = Ui.dp(this, 16); bar.setPadding(p * 2, p, p * 2, 0); homeButton = Ui.iconButton(this, R.drawable.ic_launcher, "Home", v -> show(HOME)); bar.addView(homeButton); bar.addView(new Space(this), new LinearLayout.LayoutParams(0, 1, 1)); bar.addView(Ui.iconButton(this, R.drawable.ic_reels, "Reels", v -> Playlist.openReels(this))); likedButton = Ui.iconButton(this, R.drawable.ic_favorite, "Liked", v -> show(LIKED)); bar.addView(likedButton); foldersButton = Ui.iconButton(this, R.drawable.ic_folder, "Folders", v -> show(FOLDERS)); bar.addView(foldersButton); bar.addView(new Space(this), new LinearLayout.LayoutParams(0, 1, 1)); bar.addView(Ui.iconButton(this, R.drawable.ic_settings, "Settings", v -> startActivity(new Intent(this, SettingsActivity.class)))); return bar; } private void connect() { if (connecting) return; message("Connecting…"); connecting = true; Api.async(() -> { if (!Prefs.password().isEmpty()) Api.login(Prefs.server(), Prefs.password()); Likes.load(); return Api.randomFeed(); }, posts -> { connecting = false; if (isDestroyed()) return; ready = true; backgrounds = posts; handler.removeCallbacks(rotate); rotateBackground(); show(tab); }, e -> { connecting = false; if (!isDestroyed()) failed(e); }); } private void failed(Exception e) { LinearLayout box = Ui.column(this); box.addView(Ui.text(this, "Cannot reach the instance: " + e.getMessage(), 18)); LinearLayout row = Ui.row(this); row.addView(Ui.iconButton(this, R.drawable.ic_refresh, "Retry", v -> connect())); row.addView(Ui.iconButton(this, R.drawable.ic_swap, "Change instance", v -> startActivity(new Intent(this, SetupActivity.class)))); box.addView(row); setContent(box); row.getChildAt(0).requestFocus(); } private void show(int t) { if (!ready) return; tab = t; Ui.markCurrent(homeButton, t == HOME); Ui.markCurrent(likedButton, t == LIKED); Ui.markCurrent(foldersButton, t == FOLDERS); if (t == HOME) { if (homeList != null) { setContent(homeList); return; } message("Loading…"); if (homeLoading) return; // the request in flight shows the list when it lands homeLoading = true; Api.async(Api::folders, folders -> { homeLoading = false; homeRows = new HomeRows(this, folders); homeList = Ui.list(this, RecyclerView.VERTICAL); homeList.setAdapter(homeRows); if (tab == HOME) setContent(homeList); }, e -> { homeLoading = false; if (tab == t) failed(e); }); } else if (t == LIKED) { message("Loading…"); Api.async(Api::likes, posts -> { if (tab == LIKED) setContent(posts.isEmpty() ? centered("No liked posts yet") : Ui.grid(this, Ui.postTiles(this, posts), Ui.POST_W, Ui.POST_H, 6)); }, e -> { if (tab == t) failed(e); }); } else { message("Loading…"); Api.async(Api::folders, folders -> { if (tab != FOLDERS) return; List tiles = new ArrayList<>(); for (Api.Folder f : folders) tiles.add(new TileAdapter.Tile(f.avatarUrl, f.name, R.drawable.ic_folder, -1, () -> FolderActivity.open(this, f))); setContent(Ui.grid(this, tiles, 160, 160, 5)); }, e -> { if (tab == t) failed(e); }); } } @Override protected void onResume() { super.onResume(); resumed = true; if (!ready) { if (!connecting && !App.locked) connect(); // also retries after an error, e.g. back from Settings return; } handler.removeCallbacks(rotate); handler.postDelayed(rotate, Prefs.backgroundSeconds() * 1000L); if (tab == LIKED) show(LIKED); // unliked posts drop out else if (homeRows != null) homeRows.refreshHearts(homeList); } @Override protected void onPause() { super.onPause(); resumed = false; handler.removeCallbacks(rotate); } /** Back from Liked or Folders returns to Home; from Home it leaves the app. */ @Override @SuppressWarnings("deprecation") // no predictive back on this TV; onBackPressed still fires public void onBackPressed() { if (ready && tab != HOME) show(HOME); else super.onBackPressed(); } @Override protected void onDestroy() { handler.removeCallbacks(rotate); super.onDestroy(); } private void rotateBackground() { if (backgrounds.isEmpty()) return; Post p = backgrounds.get(ThreadLocalRandom.current().nextInt(backgrounds.size())); // Never pass the old drawable as a Glide placeholder: Glide may recycle its bitmap. ImageView back = layers[1 - front], top = layers[front]; back.animate().cancel(); back.setAlpha(0f); back.bringToFront(); Glide.with(back).load(p.media.get(0).thumbnailUrl).listener(new RequestListener() { @Override public boolean onLoadFailed(GlideException e, Object model, Target target, boolean first) { return false; } @Override public boolean onResourceReady(Drawable d, Object model, Target target, DataSource source, boolean first) { front = 1 - front; // only now, so a failed load leaves the old layer on top back.animate().alpha(1f).setDuration(1500).withEndAction(() -> { if (!isDestroyed()) Glide.with(top).clear(top); }).start(); return false; } }).into(back); if (resumed) handler.postDelayed(rotate, Prefs.backgroundSeconds() * 1000L); } private void message(String s) { setContent(centered(s)); } private TextView centered(String s) { TextView t = Ui.text(this, s, 18); t.setGravity(Gravity.CENTER); return t; } private void setContent(View v) { content.removeAllViews(); content.addView(v, FILL); } }