aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:21:13 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:21:13 +0200
commitb86d85b16399827bc64583bee3d0283259020a14 (patch)
tree97cbd94740e12bcb570d3857d772da960fc6ad9e
parent44d4a132c193907ee53de180ad28e697828009e3 (diff)
downloadmygram-droid-b86d85b16399827bc64583bee3d0283259020a14.tar.gz
mygram-droid-b86d85b16399827bc64583bee3d0283259020a14.zip
Add fullscreen viewer with blurred backdrop, auto-advance and double-OK likes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--app/src/main/java/mygram/droid/ViewerActivity.java364
1 files changed, 364 insertions, 0 deletions
diff --git a/app/src/main/java/mygram/droid/ViewerActivity.java b/app/src/main/java/mygram/droid/ViewerActivity.java
index 9a3be55..e422104 100644
--- a/app/src/main/java/mygram/droid/ViewerActivity.java
+++ b/app/src/main/java/mygram/droid/ViewerActivity.java
@@ -15,6 +15,370 @@
package mygram.droid;
import android.app.Activity;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.media.MediaMetadataRetriever;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.OptIn;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.PlaybackException;
+import androidx.media3.common.Player;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.ui.PlayerView;
+
+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.HashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Fullscreen player over a blurred backdrop. Up/Down: post. Left/Right: slide,
+ * or seek on a single video. OK: pause. Double OK: like.
+ */
public final class ViewerActivity extends Activity {
+ private static final long SEEK_MS = 10_000, TICK_MS = 100;
+ /** Frame grabs block on the network; keep them off the API pool. */
+ private static final ExecutorService FRAMES = Executors.newSingleThreadExecutor();
+ 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 ticker = this::tick;
+ private final DoubleOk doubleOk = new DoubleOk();
+ private ExoPlayer player;
+ private PlayerView video;
+ private ImageView backdrop, photo, pauseIcon, heart, bigHeart;
+ private ProgressBar progress;
+ private TextView message;
+ private int index, slide;
+ /** Bumped on every slide change; late callbacks for older slides are dropped. */
+ private volatile int generation;
+ private boolean paused, failed, loadingMore;
+ private long photoElapsed;
+
+ @Override
+ @OptIn(markerClass = UnstableApi.class)
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ FrameLayout root = new FrameLayout(this);
+ root.setBackgroundColor(Color.BLACK);
+ backdrop = new ImageView(this);
+ backdrop.setScaleType(ImageView.ScaleType.CENTER_CROP);
+ Ui.blur(backdrop, 40);
+ root.addView(backdrop, FILL);
+ View dim = new View(this);
+ dim.setBackgroundColor(0x66000000);
+ root.addView(dim, FILL);
+ photo = new ImageView(this);
+ photo.setScaleType(ImageView.ScaleType.FIT_CENTER);
+ root.addView(photo, FILL);
+ player = new ExoPlayer.Builder(this).build();
+ player.addListener(new Player.Listener() {
+ @Override
+ public void onPlaybackStateChanged(int s) {
+ if (s == Player.STATE_ENDED)
+ advance();
+ }
+
+ @Override
+ public void onPlayerError(PlaybackException e) {
+ fail();
+ }
+ });
+ video = new PlayerView(this);
+ video.setUseController(false);
+ video.setShutterBackgroundColor(Color.TRANSPARENT);
+ video.setPlayer(player);
+ root.addView(video, FILL);
+ message = Ui.text(this, "", 20);
+ message.setGravity(Gravity.CENTER);
+ root.addView(message, FILL);
+ progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
+ progress.setMax(1000);
+ root.addView(progress, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ Ui.dp(this, 4), Gravity.BOTTOM));
+ pauseIcon = overlay(root, R.drawable.ic_pause, "Paused", 32, Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
+ heart = overlay(root, R.drawable.ic_favorite, "Liked", 32, Gravity.BOTTOM | Gravity.END);
+ bigHeart = overlay(root, R.drawable.ic_favorite, "Liked", 160, Gravity.CENTER);
+ heart.setColorFilter(Ui.RED);
+ bigHeart.setColorFilter(Ui.RED);
+ setContentView(root);
+
+ if (!Playlist.posts.isEmpty()) {
+ show(Playlist.start);
+ } else if (Playlist.reelsSeed != 0) {
+ message.setText("Loading…");
+ maybeLoadMore();
+ } else {
+ finish(); // process restarted; the list is gone
+ }
+ }
+
+ private ImageView overlay(FrameLayout root, int icon, String description, int sizeDp, int gravity) {
+ ImageView v = new ImageView(this);
+ v.setImageResource(icon);
+ v.setContentDescription(description);
+ v.setVisibility(View.GONE);
+ int s = Ui.dp(this, sizeDp), m = Ui.dp(this, 24);
+ FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(s, s, gravity);
+ lp.setMargins(m, m, m, m);
+ root.addView(v, lp);
+ return v;
+ }
+
+ private Post post() {
+ return Playlist.posts.get(index);
+ }
+
+ private Post.Media media() {
+ return post().media.get(slide);
+ }
+
+ private void show(int i) {
+ index = i;
+ slide = 0;
+ maybeLoadMore();
+ showSlide();
+ }
+
+ private void showSlide() {
+ Post.Media m = media();
+ int gen = ++generation;
+ paused = false;
+ failed = false;
+ photoElapsed = 0;
+ message.setText("");
+ pauseIcon.setVisibility(View.GONE);
+ heart.setVisibility(Likes.has(post().id) ? View.VISIBLE : View.GONE);
+ Glide.with(this).load(m.video ? m.thumbnailUrl : m.previewUrl).override(200).into(backdrop);
+ if (m.video) {
+ Glide.with(this).clear(photo);
+ video.setVisibility(View.VISIBLE);
+ player.setMediaItem(MediaItem.fromUri(m.previewUrl));
+ player.prepare();
+ player.play();
+ randomFrame(m, gen);
+ } else {
+ player.stop();
+ video.setVisibility(View.GONE);
+ Glide.with(this).load(m.previewUrl).listener(new RequestListener<Drawable>() {
+ @Override
+ public boolean onLoadFailed(GlideException e, Object model, Target<Drawable> target, boolean first) {
+ if (gen == generation)
+ fail();
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(Drawable d, Object model, Target<Drawable> target, DataSource source,
+ boolean first) {
+ return false;
+ }
+ }).into(photo);
+ }
+ tick();
+ }
+
+ /** Swaps the video backdrop for a frame from 20-80 % of the clip; ends are often black. */
+ private void randomFrame(Post.Media m, int gen) {
+ if (m.durationMs <= 0)
+ return;
+ long us = (long) (m.durationMs * 1000 * (0.2 + 0.6 * ThreadLocalRandom.current().nextDouble()));
+ Api.async(FRAMES, () -> {
+ if (gen != generation)
+ return null;
+ try (MediaMetadataRetriever r = new MediaMetadataRetriever()) {
+ r.setDataSource(m.previewUrl, new HashMap<>(Api.cookieHeaders(m.previewUrl)));
+ return r.getScaledFrameAtTime(us, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, 320, 180);
+ }
+ }, frame -> {
+ if (frame != null && gen == generation) {
+ Glide.with(this).clear(backdrop);
+ backdrop.setImageBitmap(frame);
+ }
+ }, e -> {
+ // keep the blurred server thumbnail
+ });
+ }
+
+ /** Drives the progress bar and the photo timer. */
+ private void tick() {
+ handler.removeCallbacks(ticker);
+ if (media().video && !failed) {
+ long d = player.getDuration();
+ progress.setProgress(d > 0 ? (int) (1000 * player.getCurrentPosition() / d) : 0);
+ } else {
+ if (!paused)
+ photoElapsed += TICK_MS;
+ long total = Prefs.photoSeconds() * 1000L;
+ progress.setProgress((int) (1000 * Math.min(photoElapsed, total) / total));
+ if (photoElapsed >= total) {
+ advance();
+ return;
+ }
+ }
+ handler.postDelayed(ticker, TICK_MS);
+ }
+
+ private void advance() {
+ if (slide < post().media.size() - 1) {
+ slide++;
+ showSlide();
+ } else if (index < Playlist.posts.size() - 1) {
+ show(index + 1);
+ } else {
+ setPaused(true); // end of the list
+ }
+ }
+
+ private void fail() {
+ failed = true;
+ photoElapsed = 0;
+ message.setText("Cannot load this post");
+ if (media().video)
+ player.stop();
+ }
+
+ private void setPaused(boolean p) {
+ paused = p;
+ pauseIcon.setVisibility(p ? View.VISIBLE : View.GONE);
+ if (media().video && !failed) {
+ if (p)
+ player.pause();
+ else
+ player.play();
+ }
+ }
+
+ private void step(int dir) {
+ if (post().media.size() > 1) {
+ int s = slide + dir;
+ if (s >= 0 && s < post().media.size()) {
+ slide = s;
+ showSlide();
+ }
+ } else if (media().video && !failed) {
+ player.seekTo(Math.max(0, player.getCurrentPosition() + dir * SEEK_MS));
+ }
+ }
+
+ private void toggleLike() {
+ Post p = post();
+ boolean on = !Likes.has(p.id);
+ Likes.set(p.id, on);
+ heart.setVisibility(on ? View.VISIBLE : View.GONE);
+ if (on) {
+ bigHeart.setAlpha(1f);
+ bigHeart.setVisibility(View.VISIBLE);
+ bigHeart.animate().alpha(0f).setDuration(700).withEndAction(() -> bigHeart.setVisibility(View.GONE)).start();
+ }
+ Api.async(() -> {
+ Api.setLike(p.id, on);
+ return true;
+ }, ok -> {
+ }, e -> {
+ Likes.set(p.id, !on);
+ if (!Playlist.posts.isEmpty() && post() == p)
+ heart.setVisibility(on ? View.GONE : View.VISIBLE);
+ Toast.makeText(this, "Like failed: " + e.getMessage(), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ /** Reels only: fetches the next page when three posts remain. */
+ private void maybeLoadMore() {
+ if (Playlist.reelsSeed == 0 || loadingMore || index < Playlist.posts.size() - 3)
+ return;
+ loadingMore = true;
+ int page = Playlist.reelsPage + 1;
+ Api.async(() -> Api.reels(Playlist.reelsSeed, page), o -> {
+ loadingMore = false;
+ Playlist.reelsPage = page;
+ boolean first = Playlist.posts.isEmpty();
+ Playlist.posts.addAll(Post.list(o, Prefs.server()));
+ if (!o.optBoolean("hasMore"))
+ Playlist.reelsSeed = 0;
+ if (first && !Playlist.posts.isEmpty())
+ show(0);
+ }, e -> {
+ loadingMore = false;
+ if (Playlist.posts.isEmpty())
+ message.setText("Cannot load reels. Press Down to retry.");
+ else
+ Toast.makeText(this, "Cannot load more: " + e.getMessage(), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ @Override
+ public boolean onKeyDown(int code, KeyEvent e) {
+ if (Playlist.posts.isEmpty()) {
+ if (code == KeyEvent.KEYCODE_DPAD_DOWN)
+ maybeLoadMore();
+ return super.onKeyDown(code, e);
+ }
+ switch (code) {
+ case KeyEvent.KEYCODE_DPAD_UP:
+ if (index > 0)
+ show(index - 1);
+ return true;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ if (index < Playlist.posts.size() - 1)
+ show(index + 1);
+ else
+ maybeLoadMore(); // retry a failed page
+ return true;
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ step(-1);
+ return true;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ step(1);
+ return true;
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ case KeyEvent.KEYCODE_ENTER:
+ if (e.getRepeatCount() > 0)
+ return true;
+ // Pause at once; a second press undoes it and likes instead.
+ setPaused(!paused);
+ if (doubleOk.press(e.getEventTime()))
+ toggleLike();
+ return true;
+ default:
+ return super.onKeyDown(code, e);
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (!Playlist.posts.isEmpty() && !paused)
+ setPaused(true);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ handler.removeCallbacksAndMessages(null);
+ generation++;
+ player.release();
+ }
}