aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:39:34 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:39:34 +0200
commit1ffa52946d8de76806b0b467ccd60f4b2fe57683 (patch)
tree52892e10f178225edd51bdd3b5c174c7e3de2513
parent40b12e5b4cd6a4352e37cd1b6e85b1da10624436 (diff)
downloadmygram-droid-1ffa52946d8de76806b0b467ccd60f4b2fe57683.tar.gz
mygram-droid-1ffa52946d8de76806b0b467ccd60f4b2fe57683.zip
Lazy-load folder content in random page order
A folder with several thousand posts took ~10 s to open because FolderActivity and the Home row OK handler fetched every page before showing anything. Pages are now fetched one at a time in a shuffled page order (each page shuffled locally), the grid shows after the first page, and the viewer shares the list and pager so it keeps paging the folder. Reels use the same generic pager. Not a full shuffle: neighbours within a page stay together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-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