/* * 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.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.os.SystemClock; 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.List; 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(); /** One thread, so like toggles reach the server in order. */ private static final ExecutorService LIKES = 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, parkedAtEnd, shown, stopped; private long photoElapsed, lastTick; @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.more != null) { 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; shown = false; photoElapsed = 0; lastTick = SystemClock.uptimeMillis(); parkedAtEnd = false; message.setText(""); pauseIcon.setVisibility(View.GONE); heart.setVisibility(Likes.has(post().id) ? View.VISIBLE : View.GONE); Glide.with(backdrop).load(m.video ? m.thumbnailUrl : m.previewUrl).override(200).into(backdrop); if (m.video) { Glide.with(photo).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(photo).load(m.previewUrl).listener(new RequestListener() { @Override public boolean onLoadFailed(GlideException e, Object model, Target target, boolean first) { if (gen == generation) fail(); return false; } @Override public boolean onResourceReady(Drawable d, Object model, Target target, DataSource source, boolean first) { if (gen == generation) { // count from when the photo is on screen shown = true; photoElapsed = 0; lastTick = SystemClock.uptimeMillis(); } return false; } }).into(photo); } tick(); if (stopped) { // page arrived in the background: stay paused, onStart re-prepares setPaused(true); player.stop(); } } // ponytail: a grab already blocked in setDataSource is not interrupted, so a slow one delays the next post's frame; upgrade: a timeout or per-post cancellation. /** 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(backdrop).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); long now = SystemClock.uptimeMillis(), dt = now - lastTick; lastTick = now; if (media().video && !failed) { long d = player.getDuration(); progress.setProgress(d > 0 ? (int) (1000 * player.getCurrentPosition() / d) : 0); } else { if (!paused && (shown || failed)) photoElapsed += dt; 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 { parkedAtEnd = true; 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; if (!p) lastTick = SystemClock.uptimeMillis(); 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(LIKES, () -> { Api.setLike(p.id, on); return true; }, ok -> { }, e -> { if (Likes.has(p.id) != on) return; // a newer toggle wins Likes.set(p.id, !on); if (!isDestroyed() && post() == p) heart.setVisibility(on ? View.GONE : View.VISIBLE); String msg = e.getMessage(); Toast.makeText(this, "Like failed" + (msg != null ? ": " + msg : ""), Toast.LENGTH_SHORT).show(); }); } /** Fetches the next page when three posts remain. */ private void maybeLoadMore() { if (Playlist.more == null || loadingMore || index < Playlist.posts.size() - 3) return; loadingMore = true; List list = Playlist.posts; Api.async(Playlist.more, page -> { if (isDestroyed() || Playlist.posts != list) return; loadingMore = false; boolean first = list.isEmpty(); int old = list.size(); if (page.isEmpty()) Playlist.more = null; Playlist.append(page); if (first && !list.isEmpty()) show(0); else if (parkedAtEnd && index == old - 1 && list.size() > old) show(index + 1); else if (list.isEmpty() && Playlist.more == null) message.setText("Nothing to show"); }, e -> { if (isDestroyed()) return; loadingMore = false; if (Playlist.posts.isEmpty()) message.setText("Cannot load posts. 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 onStart() { super.onStart(); stopped = false; // The first show() already prepared (state is not IDLE), so this only revives a stopped decoder. if (!Playlist.posts.isEmpty() && media().video && !failed && player.getPlaybackState() == Player.STATE_IDLE) { player.setPlayWhenReady(!paused); player.prepare(); } } @Override protected void onStop() { super.onStop(); stopped = true; player.stop(); // free the video decoder while in the background } @Override protected void onPause() { super.onPause(); if (!Playlist.posts.isEmpty() && !paused) setPaused(true); } @Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacksAndMessages(null); generation++; Glide.with(photo).clear(photo); Glide.with(backdrop).clear(backdrop); player.release(); } }