From c296ff5aded5924a4e08e31b9220b3c9b36eda55 Mon Sep 17 00:00:00 2001 From: nino Date: Fri, 23 Oct 2026 17:25:42 -0600 Subject: [PATCH] Albums: name search and sort by name / modification date The album grid now shows a filtered + sorted view of the list: - SQUARE (or tap the header pill) opens the keyboard to filter albums by name (case-insensitive); O clears the filter before going back. - SELECT (or tap the chip) cycles the order: modified newest/oldest, name A-Z/Z-A. Uses the album's updatedAt from GET /api/albums. - Cover eviction no longer drops a texture that a visible card still uses when two albums share the same cover photo. - README: albums controls. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++ src/albums.c | 283 +++++++++++++++++++++++++++++++++++++++++++-------- src/api.c | 9 ++ src/app.c | 11 +- src/main.c | 2 + src/state.c | 3 + src/ui.c | 2 +- 7 files changed, 275 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7eb4d61..8133ad8 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,17 @@ handheld. | SELECT | Sync overview | | START | Exit | +**Albums** + +| Button | Action | +| --- | --- | +| D‑pad / drag | Move selection / scroll | +| X | Open the album | +| □ (or tap the search bar) | Search albums by name | +| SELECT (or tap the chip) | Sort: modified newest → oldest → name A‑Z → Z‑A | +| △ | Reload the album list | +| O | Clear the search, then back | + **Places map** | Button | Action | diff --git a/src/albums.c b/src/albums.c index c02580a..e9162fe 100644 --- a/src/albums.c +++ b/src/albums.c @@ -9,7 +9,11 @@ /* albums page */ /* */ /* Opened from the albums button on the top bar. A grid of album cards */ -/* (cover, name, item count). Covers are ordinary server thumbnails: */ +/* (cover, name, item count). The grid shows a VIEW of the list: */ +/* g_alb_view holds g_album_* indices filtered by the name search */ +/* (SQUARE / tap the pill) and sorted by name or last modification */ +/* (SELECT / tap the chip). g_alb_sel is a position in that view. */ +/* Covers are ordinary server thumbnails: */ /* each album's cover id becomes a g_asset entry (asset_for_id) and the */ /* shared thumbnail worker loads it into g_thumb[]. Only rows near the */ /* viewport keep their cover texture (libraries can have hundreds of */ @@ -26,19 +30,114 @@ #define ALB_X0 ((SCREEN_W - ALB_COLS * ALB_CW - (ALB_COLS - 1) * ALB_GAP) / 2) #define ALB_GLYPH 44 /* album glyph bake size on this page */ -enum { ALB_NONE, ALB_BACK, ALB_OPEN, ALB_REFRESH }; +/* header controls: the search pill and the sort chip */ +#define ALB_HY 9.0f /* top of pill + chip */ +#define ALB_HH 38.0f /* their height */ +#define ALB_SX 190.0f /* search pill */ +#define ALB_SW 380.0f +#define ALB_OX (ALB_SX + ALB_SW + 14.0f) /* sort chip */ +#define ALB_OW 230.0f -static int g_alb_sel; /* selected album */ +enum { ALB_NONE, ALB_BACK, ALB_OPEN, ALB_REFRESH, ALB_SEARCH }; + +/* sort orders, cycled by SELECT */ +enum { ALB_SORT_MOD_NEW, ALB_SORT_MOD_OLD, ALB_SORT_NAME_AZ, ALB_SORT_NAME_ZA, + ALB_NSORT }; +static const char *const alb_sort_label[ALB_NSORT] = { + "Modified: newest", "Modified: oldest", "Name: A-Z", "Name: Z-A", +}; + +static int g_alb_sel; /* selected position in the view */ static float g_alb_scroll, g_alb_target; static int g_alb_touch, g_alb_dragged; static float g_alb_tx, g_alb_ty, g_alb_t0y, g_alb_s0; +static int g_alb_sort; /* ALB_SORT_* */ +static char g_alb_query[128]; /* name filter, "" = all albums */ +static int *g_alb_view; /* g_album_* indices, filtered+sorted */ +static int g_alb_vlen, g_alb_vcap; + static float alb_content_h(void) { - int rows = (g_album_len + ALB_COLS - 1) / ALB_COLS; + int rows = (g_alb_vlen + ALB_COLS - 1) / ALB_COLS; return ALB_TOP + rows * ALB_ROWH; } +/* ASCII case-insensitive substring test (other UTF-8 bytes match exactly) */ +static int alb_name_match(const char *name, const char *q) +{ + if (!q[0]) + return 1; + for (; *name; name++) { + const char *a = name, *b = q; + while (*a && *b && tolower((unsigned char)*a) == + tolower((unsigned char)*b)) { + a++; + b++; + } + if (!*b) + return 1; + } + return 0; +} + +static int alb_cmp(const void *pa, const void *pb) +{ + int a = *(const int *)pa, b = *(const int *)pb, r = 0; + switch (g_alb_sort) { + case ALB_SORT_MOD_NEW: + r = strcmp(g_album_updated[b], g_album_updated[a]); + break; + case ALB_SORT_MOD_OLD: + r = strcmp(g_album_updated[a], g_album_updated[b]); + break; + case ALB_SORT_NAME_AZ: + r = strcasecmp(g_album_name[a], g_album_name[b]); + break; + case ALB_SORT_NAME_ZA: + r = strcasecmp(g_album_name[b], g_album_name[a]); + break; + } + return r ? r : a - b; /* ties keep the server's order */ +} + +/* rebuild g_alb_view from the list, the query and the sort order. the + * selected album stays selected when it is still in the view (a refresh); + * callers wanting the first card set g_alb_sel = -1 first. */ +static void albums_rebuild_view(void) +{ + int keep = (g_alb_sel >= 0 && g_alb_sel < g_alb_vlen) ? + g_alb_view[g_alb_sel] : -1; + g_album_list_dirty = 0; + g_alb_vlen = 0; + if (g_album_len > g_alb_vcap) { + int cap = GROW(g_alb_view, g_alb_vcap, g_album_len); + if (!cap) + return; + g_alb_vcap = cap; + } + for (int a = 0; a < g_album_len; a++) + if (alb_name_match(g_album_name[a], g_alb_query)) + g_alb_view[g_alb_vlen++] = a; + qsort(g_alb_view, g_alb_vlen, sizeof(g_alb_view[0]), alb_cmp); + + g_alb_sel = 0; + for (int v = 0; v < g_alb_vlen; v++) + if (g_alb_view[v] == keep) + g_alb_sel = v; + log_line("albums view: %d of %d (sort %d, query \"%s\")", g_alb_vlen, + g_album_len, g_alb_sort, g_alb_query); +} + +/* new name filter (from the keyboard); "" shows every album */ +static void albums_set_query(const char *q) +{ + snprintf(g_alb_query, sizeof(g_alb_query), "%s", q); + g_alb_sel = -1; /* start at the first match */ + albums_rebuild_view(); + g_alb_scroll = g_alb_target = 0; +} + static void alb_card_xy(int a, float *x, float *y) { *x = ALB_X0 + (a % ALB_COLS) * (ALB_CW + ALB_GAP); @@ -89,12 +188,22 @@ static void albums_pump_covers(int first, int last) /* rows more than one row outside the viewport drop their texture */ int keep0 = first - ALB_COLS, keep1 = last + ALB_COLS; - for (int a = 0; a < g_album_len; a++) { - if (a >= keep0 && a <= keep1) + if (keep0 < 0) + keep0 = 0; + if (keep1 >= g_alb_vlen) + keep1 = g_alb_vlen - 1; + for (int v = 0; v < g_alb_vlen; v++) { + if (v >= keep0 && v <= keep1) continue; - int idx = g_album_cover[a]; - if (idx >= 0 && g_thumb[idx] && - !(g_req_state != REQ_IDLE && g_req_idx == idx)) { + int idx = g_album_cover[g_alb_view[v]]; + if (idx < 0 || !g_thumb[idx] || + (g_req_state != REQ_IDLE && g_req_idx == idx)) + continue; + /* albums can share a cover photo: keep it if a kept card uses it */ + int shared = 0; + for (int k = keep0; k <= keep1 && !shared; k++) + shared = g_album_cover[g_alb_view[k]] == idx; + if (!shared) { tex_release(g_thumb[idx]); g_thumb[idx] = NULL; } @@ -103,8 +212,11 @@ static void albums_pump_covers(int first, int last) if (g_req_state != REQ_IDLE) return; for (int k = -1; k <= last - first; k++) { - int a = (k < 0) ? g_alb_sel : first + k; /* selection first */ - if (a < 0 || a >= g_album_len || !g_album_thumb[a][0]) + int v = (k < 0) ? g_alb_sel : first + k; /* selection first */ + if (v < 0 || v >= g_alb_vlen) + continue; + int a = g_alb_view[v]; + if (!g_album_thumb[a][0]) continue; if (g_album_cover[a] < 0) g_album_cover[a] = asset_for_id(g_album_thumb[a]); @@ -160,14 +272,89 @@ static void draw_album_card(int a, float x, float y, int selected) draw_text(x, y + ALB_COVH + 44, RGBA8(140, 140, 148, 255), 0.8f, meta); } +/* header: search pill (query or placeholder) and the sort chip */ +static void draw_albums_header(int n) +{ + vita2d_draw_rectangle(0, 0, SCREEN_W, ALB_TOP - 8, RGBA8(16, 16, 16, 255)); + int gs = 24; + vita2d_texture *og = icon_tex(ICON_CIRCLE, gs); + if (og) + vita2d_draw_texture_tint(og, 18, 16, RGBA8(235, 90, 85, 255)); + /* same bake size as the card placeholder: icon_tex caches one + * size per glyph, so mixing sizes in a frame re-bakes (and frees) + * the texture mid-frame */ + vita2d_texture *ag = icon_tex(ICON_ALBUM, ALB_GLYPH); + if (ag) + vita2d_draw_texture_tint_scale(ag, 56, 14, 28.0f / ALB_GLYPH, + 28.0f / ALB_GLYPH, + RGBA8(235, 235, 240, 255)); + draw_text(94, 37, RGBA8(235, 235, 240, 255), 1.1f, "Albums"); + + uint32_t field = RGBA8(38, 38, 42, 255); + uint32_t dim = RGBA8(140, 140, 148, 255); + uint32_t txt = RGBA8(235, 235, 240, 255); + float ty = ALB_HY + ALB_HH - 12; + + /* search pill: [search] query ... [] (or O to clear) */ + vita2d_texture *pm = rounded_mask_tex((int)ALB_SW, (int)ALB_HH, 10.0f); + if (pm) + vita2d_draw_texture_tint(pm, ALB_SX, ALB_HY, field); + else + vita2d_draw_rectangle(ALB_SX, ALB_HY, ALB_SW, ALB_HH, field); + vita2d_texture *si = icon_tex(ICON_SEARCH, 24); + if (si) + vita2d_draw_texture_tint(si, ALB_SX + 10, ALB_HY + (ALB_HH - 24) / 2, + RGBA8(190, 190, 196, 255)); + char line[96]; + if (g_alb_query[0]) { + alb_fit(line, sizeof(line), g_alb_query, 0.9f, (int)ALB_SW - 84); + draw_text(ALB_SX + 42, ty, txt, 0.9f, line); + if (og) /* O clears the filter */ + vita2d_draw_texture_tint(og, ALB_SX + ALB_SW - 34, + ALB_HY + (ALB_HH - gs) / 2, + RGBA8(235, 90, 85, 255)); + } else { + draw_text(ALB_SX + 42, ty, dim, 0.9f, "Search albums"); + } + if (!g_alb_query[0]) { + vita2d_texture *sq = icon_tex(ICON_SQUARE, 20); + if (sq) + vita2d_draw_texture_tint(sq, ALB_SX + ALB_SW - 32, + ALB_HY + (ALB_HH - 20) / 2, + RGBA8(232, 120, 175, 255)); + } + + /* sort chip: SELECT + the current order */ + vita2d_texture *cm = rounded_mask_tex((int)ALB_OW, (int)ALB_HH, 10.0f); + if (cm) + vita2d_draw_texture_tint(cm, ALB_OX, ALB_HY, field); + else + vita2d_draw_rectangle(ALB_OX, ALB_HY, ALB_OW, ALB_HH, field); + draw_text(ALB_OX + 12, ty - 1, dim, 0.7f, "SELECT"); + draw_text(ALB_OX + 12 + text_width(0.7f, "SELECT") + 10, ty, txt, 0.9f, + alb_sort_label[g_alb_sort]); + + char cnt[64]; + if (g_alb_query[0]) + snprintf(cnt, sizeof(cnt), "%d of %d", n, g_album_len); + else + snprintf(cnt, sizeof(cnt), "%d album%s", n, n == 1 ? "" : "s"); + draw_text(SCREEN_W - 18 - text_width(0.8f, cnt), 36, dim, 0.8f, cnt); +} + /* one frame of the album list: input, cover loading and drawing. returns - * ALB_OPEN (open g_alb_sel), ALB_BACK, ALB_REFRESH or ALB_NONE. */ + * ALB_OPEN (open view position g_alb_sel), ALB_BACK, ALB_REFRESH, + * ALB_SEARCH (bring up the keyboard) or ALB_NONE. */ static int albums_page(unsigned int pressed, unsigned int nav) { int action = ALB_NONE; - int n = g_album_len; + if (g_album_list_dirty) + albums_rebuild_view(); + int n = g_alb_vlen; if (g_alb_sel >= n) g_alb_sel = n > 0 ? n - 1 : 0; + if (g_alb_sel < 0) + g_alb_sel = 0; if (n > 0) { if (nav & SCE_CTRL_RIGHT && g_alb_sel + 1 < n) @@ -183,10 +370,17 @@ static int albums_page(unsigned int pressed, unsigned int nav) if (pressed & SCE_CTRL_CROSS) action = ALB_OPEN; } - if (pressed & SCE_CTRL_CIRCLE) - action = ALB_BACK; + int do_sort = (pressed & SCE_CTRL_SELECT) != 0; + if (pressed & SCE_CTRL_CIRCLE) { + if (g_alb_query[0]) + albums_set_query(""); /* O first clears the filter */ + else + action = ALB_BACK; + } if (pressed & SCE_CTRL_TRIANGLE) action = ALB_REFRESH; + if (pressed & SCE_CTRL_SQUARE) + action = ALB_SEARCH; float maxs = alb_content_h() - SCREEN_H + 16; if (maxs < 0) @@ -216,8 +410,18 @@ static int albums_page(unsigned int pressed, unsigned int nav) g_alb_ty = ty; } else if (g_alb_touch) { g_alb_touch = 0; - if (!g_alb_dragged && g_alb_ty < ALB_TOP - 8 && g_alb_tx < 140) { - action = ALB_BACK; /* tap on the header's back label */ + if (!g_alb_dragged && g_alb_ty < ALB_TOP - 8) { + /* header taps: back label, search pill (its O end + * clears an active filter), sort chip */ + if (g_alb_tx < 140) + action = ALB_BACK; + else if (g_alb_tx >= ALB_SX && g_alb_tx < ALB_SX + ALB_SW) { + if (g_alb_query[0] && g_alb_tx > ALB_SX + ALB_SW - 48) + albums_set_query(""); + else + action = ALB_SEARCH; + } else if (g_alb_tx >= ALB_OX && g_alb_tx < ALB_OX + ALB_OW) + do_sort = 1; } else if (!g_alb_dragged) { float cy = g_alb_ty + g_alb_scroll - ALB_TOP; int row = (int)(cy / ALB_ROWH); @@ -234,6 +438,13 @@ static int albums_page(unsigned int pressed, unsigned int nav) } } } + if (do_sort) { + /* a new order starts from its top */ + g_alb_sort = (g_alb_sort + 1) % ALB_NSORT; + g_alb_sel = -1; + albums_rebuild_view(); + g_alb_scroll = g_alb_target = 0; + } /* keep the selected card on screen (d-pad), with an eased scroll */ if (!g_alb_touch) { @@ -263,37 +474,23 @@ static int albums_page(unsigned int pressed, unsigned int nav) vita2d_start_drawing(); vita2d_clear_screen(); vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H, RGBA8(16, 16, 16, 255)); - for (int a = first; a <= last && a < n; a++) { + for (int v = first; v <= last && v < n; v++) { float x, y; - alb_card_xy(a, &x, &y); - draw_album_card(a, x, y - g_alb_scroll, a == g_alb_sel); + alb_card_xy(v, &x, &y); + draw_album_card(g_alb_view[v], x, y - g_alb_scroll, + v == g_alb_sel); } - if (n == 0) + if (n == 0 && g_alb_query[0]) { + char msg[160]; + snprintf(msg, sizeof(msg), "No albums match \"%.64s\"", g_alb_query); + draw_centered(SCREEN_H / 2, RGBA8(160, 160, 165, 255), msg); + } else if (n == 0) { draw_centered(SCREEN_H / 2, RGBA8(160, 160, 165, 255), "No albums on this account"); - - /* header: back label, title, count (drawn last: cards scroll under it) */ - vita2d_draw_rectangle(0, 0, SCREEN_W, ALB_TOP - 8, RGBA8(16, 16, 16, 255)); - { - int gs = 24; - vita2d_texture *og = icon_tex(ICON_CIRCLE, gs); - if (og) - vita2d_draw_texture_tint(og, 18, 16, RGBA8(235, 90, 85, 255)); - /* same bake size as the card placeholder: icon_tex caches one - * size per glyph, so mixing sizes in a frame re-bakes (and frees) - * the texture mid-frame */ - vita2d_texture *ag = icon_tex(ICON_ALBUM, ALB_GLYPH); - if (ag) - vita2d_draw_texture_tint_scale(ag, 56, 14, 28.0f / ALB_GLYPH, - 28.0f / ALB_GLYPH, - RGBA8(235, 235, 240, 255)); - draw_text(94, 37, RGBA8(235, 235, 240, 255), 1.1f, "Albums"); - char cnt[64]; - snprintf(cnt, sizeof(cnt), "%d album%s X open /\\ refresh", - n, n == 1 ? "" : "s"); - draw_text(SCREEN_W - 18 - text_width(0.8f, cnt), 36, - RGBA8(140, 140, 148, 255), 0.8f, cnt); } + + /* drawn last: cards scroll under it */ + draw_albums_header(n); vita2d_end_drawing(); vita2d_swap_buffers(); return action; diff --git a/src/api.c b/src/api.c index 3caa735..720c06f 100644 --- a/src/api.c +++ b/src/api.c @@ -468,6 +468,7 @@ static int parse_albums(const char *js, size_t len) g_album_nassets[cur] = 0; g_album_shared[cur] = 0; g_album_has_link[cur] = 0; + g_album_updated[cur][0] = '\0'; g_album_cover[cur] = -1; continue; } @@ -501,6 +502,11 @@ static int parse_albums(const char *js, size_t len) g_album_shared[cur] = (unsigned char)json_true(js, v); else if (kl == 13 && !strncmp(k, "hasSharedLink", 13)) g_album_has_link[cur] = (unsigned char)json_true(js, v); + else if (kl == 9 && !strncmp(k, "updatedAt", 9) && + v->type == JSMN_STRING && + vl < (int)sizeof(g_album_updated[0])) + snprintf(g_album_updated[cur], sizeof(g_album_updated[0]), + "%.*s", vl, js + v->start); } free(t); @@ -516,6 +522,8 @@ static int parse_albums(const char *js, size_t len) g_album_nassets[w] = g_album_nassets[r]; g_album_shared[w] = g_album_shared[r]; g_album_has_link[w] = g_album_has_link[r]; + memcpy(g_album_updated[w], g_album_updated[r], + sizeof(g_album_updated[0])); g_album_cover[w] = g_album_cover[r]; } w++; @@ -553,6 +561,7 @@ static void fetch_albums(void) return; } log_line("albums: %d fetched", n); + g_album_list_dirty = 1; __sync_synchronize(); g_album_state = 1; } diff --git a/src/app.c b/src/app.c index 1c9c547..03a0589 100644 --- a/src/app.c +++ b/src/app.c @@ -191,6 +191,8 @@ static void reset_for_account_change(void) g_album_fetching = 0; g_alb_sel = 0; g_alb_scroll = g_alb_target = 0; + g_alb_vlen = 0; + g_alb_query[0] = '\0'; g_srv_state = 0; /* re-fetch the cloud page's server info */ g_srv_fetching = 0; g_map_state = 0; /* re-fetch the new account's map markers */ @@ -1992,7 +1994,12 @@ pf_skip: } else if (act == ALB_REFRESH) { albums_release_covers(); g_album_state = 0; - } else if (act == ALB_OPEN && g_alb_sel < g_album_len) { + } else if (act == ALB_SEARCH) { + char q[128] = ""; + if (ime_input("Search albums", g_alb_query, q, + sizeof(q), 0)) + albums_set_query(q); + } else if (act == ALB_OPEN && g_alb_sel < g_alb_vlen) { /* fetch the members on a worker thread; the main * thread only animates the throbber meanwhile */ albums_release_covers(); @@ -2003,7 +2010,7 @@ pf_skip: g_search_active = 0; if (g_gallery_active) gallery_close(); - g_album_open = g_alb_sel; + g_album_open = g_alb_view[g_alb_sel]; g_album_assets_done = 0; SceUID ot = sceKernelCreateThread("album_open", album_open_thread, 0x10000100, diff --git a/src/main.c b/src/main.c index 30ebb76..a2b9ad2 100644 --- a/src/main.c +++ b/src/main.c @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/src/state.c b/src/state.c index 5052068..c0e1747 100644 --- a/src/state.c +++ b/src/state.c @@ -140,11 +140,13 @@ static char (*g_album_thumb)[40]; /* cover asset id, "" when none */ static int *g_album_nassets; /* assetCount reported by the server */ static unsigned char *g_album_shared; /* shared with other users */ static unsigned char *g_album_has_link; /* has a public share link */ +static char (*g_album_updated)[32]; /* updatedAt (ISO 8601, sorts as text) */ static int g_album_len; /* number of albums fetched */ static int g_album_cap; static int g_album_state; static volatile int g_album_fetching; +static volatile int g_album_list_dirty; /* list (re)fetched: rebuild the view */ static int g_album_active; /* grid is showing one album's assets */ static int g_album_open = -1; /* index into g_album_* being shown */ @@ -301,6 +303,7 @@ static int grow_albums(int need) !GROW(g_album_nassets, c, need) || !GROW(g_album_shared, c, need) || !GROW(g_album_has_link, c, need) || + !GROW(g_album_updated, c, need) || !(g_album_cap = GROW(g_album_cover, c, need))) return 0; return 1; diff --git a/src/ui.c b/src/ui.c index d535de6..0776f8e 100644 --- a/src/ui.c +++ b/src/ui.c @@ -66,7 +66,7 @@ static void draw_sel_outline(float x, float y, float w, float h) * texture's bilinear sampling are what soften the corners. */ static vita2d_texture *rounded_mask_tex(int w, int h, float r) { - enum { NSLOT = 14 }; /* a small fixed set of shapes (pill, buttons, ...) */ + enum { NSLOT = 20 }; /* a small fixed set of shapes (pill, buttons, ...) */ static vita2d_texture *cache[NSLOT]; static int cw[NSLOT], ch[NSLOT]; static float cr[NSLOT];