diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-22 11:21:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-22 11:21:13 +0200 |
| commit | b86d85b16399827bc64583bee3d0283259020a14 (patch) | |
| tree | 97cbd94740e12bcb570d3857d772da960fc6ad9e | |
| parent | 44d4a132c193907ee53de180ad28e697828009e3 (diff) | |
| download | mygram-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.java | 364 |
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(); + } } |
