aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/src/main/java/mygram/droid/Api.java49
-rw-r--r--app/src/main/java/mygram/droid/FolderActivity.java82
-rw-r--r--app/src/main/java/mygram/droid/HomeRows.java11
-rw-r--r--app/src/main/java/mygram/droid/Playlist.java33
-rw-r--r--app/src/main/java/mygram/droid/Ui.java2
-rw-r--r--app/src/main/java/mygram/droid/ViewerActivity.java22
-rw-r--r--docs/superpowers/specs/2026-10-22-mygram-droid-design.md14
7 files changed, 153 insertions, 60 deletions
diff --git a/app/src/main/java/mygram/droid/Api.java b/app/src/main/java/mygram/droid/Api.java
index 7cce5b6..f603b1b 100644
--- a/app/src/main/java/mygram/droid/Api.java
+++ b/app/src/main/java/mygram/droid/Api.java
@@ -165,15 +165,42 @@ final class Api {
return out;
}
- /** Every post of a folder, in server order. */
- static List<Post> folderPosts(String slug) throws IOException, JSONException {
- List<Post> all = new ArrayList<>();
- for (int page = 1;; page++) {
- JSONObject o = call("GET", "/folders/" + Uri.encode(slug) + "/images?limit=" + PAGE + "&page=" + page);
- all.addAll(Post.list(o, Prefs.server()));
- if (!o.optBoolean("hasMore"))
- return all;
- }
+ /** Pager over a folder: each call returns the next page, shuffled; empty once all pages are used. */
+ // ponytail: random page order plus in-page shuffle, not a full shuffle; neighbours within a page stay together; upgrade: fetch all ids once if that ever matters.
+ static Callable<List<Post>> folderPages(String slug, int count) {
+ List<Integer> pages = new ArrayList<>();
+ for (int p = 1; p <= (count + PAGE - 1) / PAGE; p++)
+ pages.add(p);
+ Collections.shuffle(pages);
+ String path = "/folders/" + Uri.encode(slug) + "/images?limit=" + PAGE + "&page=";
+ int[] next = {0};
+ return () -> {
+ synchronized (next) {
+ while (next[0] < pages.size()) { // imageCount counts carousel slides, so later pages may be empty
+ List<Post> posts = Post.list(call("GET", path + pages.get(next[0]++)), Prefs.server());
+ if (!posts.isEmpty()) {
+ Collections.shuffle(posts);
+ return posts;
+ }
+ }
+ return new ArrayList<>();
+ }
+ };
+ }
+
+ /** Pager over random Reels with a fixed seed; empty once the server reports no more. */
+ static Callable<List<Post>> reelsPages(int seed) {
+ int[] state = {0, 1}; // page, hasMore
+ return () -> {
+ synchronized (state) {
+ if (state[1] == 0)
+ return new ArrayList<>();
+ JSONObject o = call("GET", "/reels?mode=random&limit=12&seed=" + seed + "&page=" + ++state[0]);
+ if (!o.optBoolean("hasMore"))
+ state[1] = 0;
+ return Post.list(o, Prefs.server());
+ }
+ };
}
/** Up to n random posts of a folder. */
@@ -193,10 +220,6 @@ final class Api {
return Post.list(call("GET", "/feed?mode=random&limit=" + PAGE), Prefs.server());
}
- static JSONObject reels(int seed, int page) throws IOException, JSONException {
- return call("GET", "/reels?mode=random&limit=12&seed=" + seed + "&page=" + page);
- }
-
/** Liked posts, newest like first. */
static List<Post> likes() throws IOException, JSONException {
return Post.list(call("GET", "/likes"), Prefs.server());
diff --git a/app/src/main/java/mygram/droid/FolderActivity.java b/app/src/main/java/mygram/droid/FolderActivity.java
index 10a9940..c7002c5 100644
--- a/app/src/main/java/mygram/droid/FolderActivity.java
+++ b/app/src/main/java/mygram/droid/FolderActivity.java
@@ -23,18 +23,27 @@ import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
+import android.widget.Toast;
+import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
-import java.util.Collections;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
-/** One folder as a grid, shuffled once per open. */
+/** One folder as a grid, paged in lazily in random page order. */
public final class FolderActivity extends Activity {
private FrameLayout content;
private RecyclerView grid;
+ private final List<Post> posts = new ArrayList<>();
+ private final List<TileAdapter.Tile> tiles = new ArrayList<>();
+ private Callable<List<Post>> more;
+ private boolean loading;
static void open(Context c, Api.Folder f) {
- c.startActivity(new Intent(c, FolderActivity.class).putExtra("slug", f.slug).putExtra("name", f.name));
+ c.startActivity(new Intent(c, FolderActivity.class).putExtra("slug", f.slug).putExtra("name", f.name)
+ .putExtra("count", f.count));
}
@Override
@@ -49,19 +58,76 @@ public final class FolderActivity extends Activity {
column.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(column);
show(Ui.text(this, "Loading…", 18));
- Api.async(() -> Api.folderPosts(getIntent().getStringExtra("slug")), posts -> {
- Collections.shuffle(posts);
- grid = Ui.grid(this, Ui.postTiles(this, posts), Ui.POST_W, Ui.POST_H, 6);
+ more = Api.folderPages(getIntent().getStringExtra("slug"), getIntent().getIntExtra("count", 0));
+ loading = true;
+ Api.async(more, page -> {
+ loading = false;
+ if (isDestroyed())
+ return;
+ append(page);
+ if (page.isEmpty()) {
+ show(Ui.text(this, "Nothing to show", 18));
+ return;
+ }
+ grid = Ui.grid(this, tiles, Ui.POST_W, Ui.POST_H, 6);
+ grid.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(RecyclerView rv, int dx, int dy) {
+ loadMore();
+ }
+ });
show(grid);
grid.post(grid::requestFocus); // after layout
- }, e -> show(Ui.text(this, "Cannot load folder: " + e.getMessage(), 18)));
+ }, e -> {
+ loading = false;
+ show(Ui.text(this, "Cannot load folder: " + e.getMessage(), 18));
+ });
+ }
+
+ /** Loads the next page when the last visible tile is within 12 of the end. */
+ private void loadMore() {
+ if (more == null || loading || grid == null)
+ return;
+ if (((GridLayoutManager) grid.getLayoutManager()).findLastVisibleItemPosition() < tiles.size() - 12)
+ return;
+ loading = true;
+ Api.async(more, page -> {
+ loading = false;
+ if (isDestroyed())
+ return;
+ if (page.isEmpty())
+ more = null;
+ append(page);
+ }, e -> {
+ loading = false;
+ if (!isDestroyed())
+ Toast.makeText(this, "Cannot load more: " + e.getMessage(), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ /** Pages from one pager never overlap, so no id check is needed here. */
+ private void append(List<Post> page) {
+ posts.addAll(page);
+ addTiles();
+ }
+
+ private void addTiles() {
+ int old = tiles.size();
+ for (int i = old; i < posts.size(); i++) {
+ int start = i;
+ tiles.add(TileAdapter.post(posts.get(i), () -> Playlist.open(this, posts, start, more)));
+ }
+ if (grid != null && tiles.size() > old)
+ grid.getAdapter().notifyItemRangeInserted(old, tiles.size() - old);
}
@Override
protected void onResume() {
super.onResume();
- if (grid != null && grid.getAdapter() != null)
+ if (grid != null && grid.getAdapter() != null) {
+ addTiles(); // the viewer may have appended to the shared list
grid.getAdapter().notifyItemRangeChanged(0, grid.getAdapter().getItemCount());
+ }
}
private void show(android.view.View v) {
diff --git a/app/src/main/java/mygram/droid/HomeRows.java b/app/src/main/java/mygram/droid/HomeRows.java
index e4abeb5..6105c1c 100644
--- a/app/src/main/java/mygram/droid/HomeRows.java
+++ b/app/src/main/java/mygram/droid/HomeRows.java
@@ -20,12 +20,10 @@ import android.content.Context;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
-import android.widget.Toast;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -102,14 +100,9 @@ final class HomeRows extends RecyclerView.Adapter<HomeRows.Row> {
return folders.size();
}
- /** Viewer at p, then the rest of the folder, shuffled. */
+ /** Viewer at p at once; the rest of the folder pages in lazily, in random page order. */
private void open(Api.Folder f, Post p) {
- Api.async(() -> Api.folderPosts(f.slug), all -> {
- Collections.shuffle(all);
- all.removeIf(x -> x.id == p.id);
- all.add(0, p);
- Playlist.open(activity, all, 0);
- }, e -> Toast.makeText(activity, "Cannot load folder: " + e.getMessage(), Toast.LENGTH_LONG).show());
+ Playlist.open(activity, new ArrayList<>(List.of(p)), 0, Api.folderPages(f.slug, f.count));
}
/** Rebinds visible tiles so hearts follow likes changed in the viewer. */
diff --git a/app/src/main/java/mygram/droid/Playlist.java b/app/src/main/java/mygram/droid/Playlist.java
index 98379fb..b885397 100644
--- a/app/src/main/java/mygram/droid/Playlist.java
+++ b/app/src/main/java/mygram/droid/Playlist.java
@@ -18,32 +18,43 @@ import android.content.Context;
import android.content.Intent;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;
/** Hands a post list to ViewerActivity; intents cannot carry hundreds of posts. */
final class Playlist {
+ /** Shared with the caller (e.g. a folder grid), not copied, so both see appended pages. */
static List<Post> posts = new ArrayList<>();
static int start;
- /** Non-zero while the viewer plays endless Reels and pages for more. */
- static int reelsSeed;
- static int reelsPage;
+ /** Fetches the next page of posts; null when there is nothing more. */
+ static Callable<List<Post>> more;
private Playlist() {
}
- static void open(Context c, List<Post> list, int startIndex) {
- posts = new ArrayList<>(list);
+ static void open(Context c, List<Post> list, int startIndex, Callable<List<Post>> pager) {
+ posts = list;
start = startIndex;
- reelsSeed = 0;
+ more = pager;
c.startActivity(new Intent(c, ViewerActivity.class));
}
static void openReels(Context c) {
- posts = new ArrayList<>();
- start = 0;
- reelsSeed = 1 + ThreadLocalRandom.current().nextInt(1_000_000);
- reelsPage = 0;
- c.startActivity(new Intent(c, ViewerActivity.class));
+ open(c, new ArrayList<>(), 0, Api.reelsPages(1 + ThreadLocalRandom.current().nextInt(1_000_000)));
+ }
+
+ /** Appends the posts not already in the list; returns how many were added. */
+ static int append(List<Post> page) {
+ Set<Integer> ids = new HashSet<>();
+ for (Post p : posts)
+ ids.add(p.id);
+ int old = posts.size();
+ for (Post p : page)
+ if (ids.add(p.id))
+ posts.add(p);
+ return posts.size() - old;
}
}
diff --git a/app/src/main/java/mygram/droid/Ui.java b/app/src/main/java/mygram/droid/Ui.java
index 757b7fe..65de7f8 100644
--- a/app/src/main/java/mygram/droid/Ui.java
+++ b/app/src/main/java/mygram/droid/Ui.java
@@ -150,7 +150,7 @@ final class Ui {
List<TileAdapter.Tile> tiles = new ArrayList<>();
for (int i = 0; i < posts.size(); i++) {
int start = i;
- tiles.add(TileAdapter.post(posts.get(i), () -> Playlist.open(c, posts, start)));
+ tiles.add(TileAdapter.post(posts.get(i), () -> Playlist.open(c, posts, start, null)));
}
return tiles;
}
diff --git a/app/src/main/java/mygram/droid/ViewerActivity.java b/app/src/main/java/mygram/droid/ViewerActivity.java
index 59e6948..4845cbd 100644
--- a/app/src/main/java/mygram/droid/ViewerActivity.java
+++ b/app/src/main/java/mygram/droid/ViewerActivity.java
@@ -131,7 +131,7 @@ public final class ViewerActivity extends Activity {
if (!Playlist.posts.isEmpty()) {
show(Playlist.start);
- } else if (Playlist.reelsSeed != 0) {
+ } else if (Playlist.more != null) {
message.setText("Loading…");
maybeLoadMore();
} else {
@@ -330,35 +330,33 @@ public final class ViewerActivity extends Activity {
});
}
- /** Reels only: fetches the next page when three posts remain. */
+ /** Fetches the next page when three posts remain. */
private void maybeLoadMore() {
- if (Playlist.reelsSeed == 0 || loadingMore || index < Playlist.posts.size() - 3)
+ if (Playlist.more == null || loadingMore || index < Playlist.posts.size() - 3)
return;
loadingMore = true;
- int page = Playlist.reelsPage + 1;
List<Post> list = Playlist.posts;
- Api.async(() -> Api.reels(Playlist.reelsSeed, page), o -> {
+ Api.async(Playlist.more, page -> {
if (isDestroyed() || Playlist.posts != list)
return;
loadingMore = false;
- Playlist.reelsPage = page;
boolean first = list.isEmpty();
int old = list.size();
- list.addAll(Post.list(o, Prefs.server()));
- if (!o.optBoolean("hasMore"))
- Playlist.reelsSeed = 0;
+ 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.reelsSeed == 0)
- message.setText("No reels");
+ 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 reels. Press Down to retry.");
+ message.setText("Cannot load posts. Press Down to retry.");
else
Toast.makeText(this, "Cannot load more: " + e.getMessage(), Toast.LENGTH_SHORT).show();
});
diff --git a/docs/superpowers/specs/2026-10-22-mygram-droid-design.md b/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
index 69d0b8a..76bbbdb 100644
--- a/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
+++ b/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
@@ -78,8 +78,8 @@ reaches it.
`latestImageMtimeMs` descending, rows loaded lazily on scroll.
- Each row: about 10 random items from that folder, then a **Continue**
tile.
-- OK on an item: viewer at that item, continuing through the folder's
- shuffled list. OK on Continue: folder grid.
+- OK on an item: viewer at that item at once, then the folder pages in
+ lazily (random page order, shuffled within each page). OK on Continue: folder grid.
### Folders
@@ -87,10 +87,12 @@ Grid of all folders as avatar tiles (`avatarUrl`). OK: folder grid.
### Folder grid
-- Fetches every page of `GET /api/folders/:slug/images` (60 per request)
- and shuffles locally once per open. The API has no random mode for
- folders.
-- OK: viewer at that item, continuing in grid order.
+- Lazy: `GET /api/folders/:slug/images` (60 per request) pages are fetched
+ in a random order (page numbers from `imageCount`, empty pages skipped),
+ each shuffled locally; the next page loads when the last visible tile is
+ within 12 of the end. The API has no random mode for folders.
+- Ceiling: not a full shuffle, neighbours within a page stay together.
+- OK: viewer at that item, continuing through the same list and pager.
### Liked