Add albums support, API layer, and Docker improvements

- Add albums browsing and navigation (src/albums.c, src/app.c)
- Extract API calls into dedicated src/api.c module
- Improve display and UI components
- Update CMakeLists.txt to include new source files
- Refactor Dockerfile for better build process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nino
2026-10-23 14:11:00 -06:00
co-authored by Claude Sonnet 4.6
parent f4cf235c40
commit 07890191cf
10 changed files with 1070 additions and 44 deletions
+4
View File
@@ -32,6 +32,10 @@ target_link_libraries(${PROJECT_NAME}
jpeg jpeg
z z
m m
# OpenSSL 1.0.2's libcrypto.a has a threads_pthread.o that references
# pthread_getspecific/pthread_self/pthread_equal; the Vita newlib stubs that
# live in libpthread.a.
pthread
SceDisplay_stub SceDisplay_stub
SceGxm_stub SceGxm_stub
SceSysmodule_stub SceSysmodule_stub
+36 -18
View File
@@ -1,28 +1,46 @@
FROM vitasdk/vitasdk:latest FROM vitasdk/vitasdk:latest
RUN apk add --no-cache \ # The published image has changed base distro over time (Alpine -> Ubuntu), so
cmake \ # probe for the package manager instead of assuming `apk`. Recent images
make \ # already ship cmake/make/git, in which case this layer is a no-op.
git \ RUN if command -v apk >/dev/null 2>&1; then \
ca-certificates apk add --no-cache cmake make git ca-certificates; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update && apt-get install -y --no-install-recommends \
cmake make git ca-certificates && rm -rf /var/lib/apt/lists/*; \
fi
# vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm # vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm; -f = force install.
# -f = force install (also works for first-time installs) #
RUN vdpm -f \ # NOTE 1: `openssl` (1.0.2) is deliberately NOT listed. The SDK's libcurl.a
zlib \ # (curl 8.17) is prebuilt against OpenSSL 1.0.2, but the image ships
libpng \ # openssl-1.1.1 as the installed default (a dependency of cpython3) and the two
libjpeg-turbo \ # vdpm packages conflict, so `vdpm openssl` aborts and leaves libcurl
freetype \ # referencing 1.0.2-only symbols (EVP_MD_CTX_create, SSLeay,
openssl \ # SSLv23_client_method, ...) that 1.1.1 does not provide -> link failure.
curl \ # The RUN below overlays the matching 1.0.2 libs/headers from the tarball.
zstd \ #
vita2d # NOTE 2: vita2d is NOT a vdpm package (it ships with the SDK core).
RUN yes | vdpm -f zlib libpng libjpeg-turbo freetype curl zstd
# Overlay the OpenSSL 1.0.2 build libcurl was compiled against (see above).
# This is the `openssl` vdpm package from the release snapshot the image's own
# package DB points at; the SHA256 is the one recorded in that DB.
ARG OPENSSL_URL=https://github.com/vitasdk/vitasdk-autobuild/releases/download/packages-2026.08-snapshot-20260925.50.1/openssl-1.0.2.r12304.gcd52124-1-vita.pkg.tar.xz
ADD ${OPENSSL_URL} /tmp/openssl102.tar.xz
RUN echo "1f97f3658fa6099354547202e2a9df6fa2919c60bcce314c83e99ef25de8998e /tmp/openssl102.tar.xz" | sha256sum -c - && \
mkdir -p /tmp/o102 && tar xJf /tmp/openssl102.tar.xz -C /tmp/o102 && \
cp -a /tmp/o102/arm-vita-eabi/lib/libcrypto.a \
/tmp/o102/arm-vita-eabi/lib/libssl.a \
/usr/local/vitasdk/arm-vita-eabi/lib/ && \
cp -a /tmp/o102/arm-vita-eabi/include/openssl/. \
/usr/local/vitasdk/arm-vita-eabi/include/openssl/ && \
rm -rf /tmp/o102 /tmp/openssl102.tar.xz
WORKDIR /src WORKDIR /src
COPY . . COPY . .
RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && \ RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && cmake --build /build --parallel 4
cmake --build /build --parallel "$(nproc)"
# Mount a host directory to /output to retrieve the VPK # Mount a host directory to /output to retrieve the VPK
CMD ["sh", "-c", "cp /build/vitaImmich.vpk /output/vitaImmich.vpk && echo 'Done: /output/vitaImmich.vpk'"] CMD ["sh", "-c", "cp /build/vitaImmich.vpk /output/vitaImmich.vpk && echo 'Done: /output/vitaImmich.vpk'"]
+18 -1
View File
@@ -2,7 +2,24 @@
Task list for the current round of work. Checked items are done. Task list for the current round of work. Checked items are done.
## -1. UI polish round 2 (current) ## -2. Albums (current)
- [x] Third top-bar button (photo-stack glyph) left of the map/cloud
buttons; d-pad focus cycles field -> albums -> map -> cloud, tap works.
- [x] Albums page: GET /api/albums, 4-column card grid (cover cropped to
4:3, name, item count, "Shared"), d-pad/tap, X open, O back,
triangle refresh. Covers load through the shared thumbnail worker
(cover id -> asset_for_id) and only rows near the viewport keep a
texture.
- [x] Opening an album fetches every member with /api/search/metadata
({"albumIds":[id]} paged by nextPage, nextCursor followed if sent)
on a worker thread with a progress throbber, then shows them on the
grid in month sections; the bar shows the album name and O (or the
red chip) returns to the album list.
- [x] Test-only scripted input (-DVIMM_TEST_INPUT, reads
ux0:/data/vitaimmich/test_input.txt) for Vita3K runs where synthetic
key events don't reach the emulator.
## -1. UI polish round 2
- [x] Reload images taken by the camera while the app was asleep: detect - [x] Reload images taken by the camera while the app was asleep: detect
resume via an RTC wall-clock jump (>3 s) and append-only re-scan the resume via an RTC wall-clock jump (>3 s) and append-only re-scan the
camera folders (headroom reserved at startup so no realloc races the camera folders (headroom reserved at startup so no realloc races the
+320
View File
@@ -0,0 +1,320 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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: */
/* 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 */
/* albums). Opening an album fills g_album_idx and the grid shows it */
/* like a search result set (see rebuild_display). */
/* ------------------------------------------------------------------ */
#define ALB_COLS 4
#define ALB_CW 210.0f /* card width */
#define ALB_COVH 158.0f /* cover height (4:3) */
#define ALB_GAP 20.0f
#define ALB_ROWH (ALB_COVH + 62.0f) /* cover + two text lines + gap */
#define ALB_TOP 64.0f /* below the page header */
#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 };
static int g_alb_sel; /* selected album */
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 float alb_content_h(void)
{
int rows = (g_album_len + ALB_COLS - 1) / ALB_COLS;
return ALB_TOP + rows * ALB_ROWH;
}
static void alb_card_xy(int a, float *x, float *y)
{
*x = ALB_X0 + (a % ALB_COLS) * (ALB_CW + ALB_GAP);
*y = ALB_TOP + (a / ALB_COLS) * ALB_ROWH;
}
/* `text` shortened with "..." so it fits `maxw` px at `scale` */
static void alb_fit(char *out, size_t outsz, const char *text, float scale,
int maxw)
{
snprintf(out, outsz, "%s", text);
if (text_width(scale, out) <= maxw)
return;
size_t n = strlen(out);
while (n > 1) {
n--;
while (n > 1 && ((unsigned char)out[n] & 0xC0) == 0x80)
n--; /* don't split a UTF-8 sequence */
snprintf(out + n, outsz - n, "...");
if (text_width(scale, out) <= maxw)
return;
}
}
/* give every cover texture back to the pool (leaving the page, or
* reloading the list); the grid reloads anything it needs */
static void albums_release_covers(void)
{
vita2d_wait_rendering_done();
for (int a = 0; a < g_album_len; a++) {
int idx = g_album_cover[a];
if (idx >= 0 && idx < g_asset_count && g_thumb[idx] &&
!(g_req_state != REQ_IDLE && g_req_idx == idx)) {
tex_release(g_thumb[idx]);
g_thumb[idx] = NULL;
}
}
}
/* thumbnail worker handoff for the visible covers: collect a finished load,
* evict covers far from the viewport, request the next missing one */
static void albums_pump_covers(int first, int last)
{
if (g_req_state == REQ_DONE) {
__sync_synchronize();
consume_worker_result();
}
/* 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)
continue;
int idx = g_album_cover[a];
if (idx >= 0 && g_thumb[idx] &&
!(g_req_state != REQ_IDLE && g_req_idx == idx)) {
tex_release(g_thumb[idx]);
g_thumb[idx] = NULL;
}
}
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])
continue;
if (g_album_cover[a] < 0)
g_album_cover[a] = asset_for_id(g_album_thumb[a]);
int idx = g_album_cover[a];
if (idx < 0 || g_thumb[idx] || g_thumb_failed[idx])
continue;
g_req_idx = idx;
g_req_src = SRC_SERVER;
g_req_detail = 0;
g_req_is_video = 0;
snprintf(g_req_id, sizeof(g_req_id), "%s", g_asset_ids[idx]);
__sync_synchronize();
g_req_state = REQ_PENDING;
return;
}
}
static void draw_album_card(int a, float x, float y, int selected)
{
uint32_t card = RGBA8(38, 38, 42, 255);
int idx = g_album_cover[a];
vita2d_texture *tex = (idx >= 0 && idx < g_asset_count) ? g_thumb[idx] : NULL;
if (selected)
draw_sel_outline(x - 5, y - 5, ALB_CW + 10, ALB_COVH + 10);
vita2d_draw_rectangle(x, y, ALB_CW, ALB_COVH, card);
if (tex) {
/* crop to fill the 4:3 cover box */
float tw = vita2d_texture_get_width(tex);
float th = vita2d_texture_get_height(tex);
float s = fmaxf(ALB_CW / tw, ALB_COVH / th);
float sw = ALB_CW / s, sh = ALB_COVH / s;
vita2d_draw_texture_part_scale(tex, x, y, (tw - sw) / 2,
(th - sh) / 2, sw, sh, s, s);
} else {
int gs = ALB_GLYPH;
vita2d_texture *g = icon_tex(ICON_ALBUM, gs);
if (g)
vita2d_draw_texture_tint(g, x + (ALB_CW - gs) / 2,
y + (ALB_COVH - gs) / 2,
RGBA8(90, 90, 98, 255));
}
char line[96];
alb_fit(line, sizeof(line), g_album_name[a][0] ? g_album_name[a] :
"(untitled album)", 0.9f, (int)ALB_CW);
draw_text(x, y + ALB_COVH + 22, selected ? RGBA8(255, 255, 255, 255) :
RGBA8(225, 225, 230, 255), 0.9f, line);
char meta[64];
snprintf(meta, sizeof(meta), "%d item%s%s", g_album_nassets[a],
g_album_nassets[a] == 1 ? "" : "s",
g_album_shared[a] ? " \xC2\xB7 Shared" : "");
draw_text(x, y + ALB_COVH + 44, RGBA8(140, 140, 148, 255), 0.8f, meta);
}
/* 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. */
static int albums_page(unsigned int pressed, unsigned int nav)
{
int action = ALB_NONE;
int n = g_album_len;
if (g_alb_sel >= n)
g_alb_sel = n > 0 ? n - 1 : 0;
if (n > 0) {
if (nav & SCE_CTRL_RIGHT && g_alb_sel + 1 < n)
g_alb_sel++;
if (nav & SCE_CTRL_LEFT && g_alb_sel > 0)
g_alb_sel--;
if (nav & SCE_CTRL_DOWN)
g_alb_sel = g_alb_sel + ALB_COLS < n ? g_alb_sel + ALB_COLS :
((g_alb_sel / ALB_COLS) < (n - 1) / ALB_COLS ? n - 1 :
g_alb_sel);
if (nav & SCE_CTRL_UP && g_alb_sel >= ALB_COLS)
g_alb_sel -= ALB_COLS;
if (pressed & SCE_CTRL_CROSS)
action = ALB_OPEN;
}
if (pressed & SCE_CTRL_CIRCLE)
action = ALB_BACK;
if (pressed & SCE_CTRL_TRIANGLE)
action = ALB_REFRESH;
float maxs = alb_content_h() - SCREEN_H + 16;
if (maxs < 0)
maxs = 0;
/* front touch: drag scrolls, a tap opens the card under it */
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f, ty = td.report[0].y * 0.5f;
if (!g_alb_touch) {
g_alb_touch = 1;
g_alb_dragged = 0;
g_alb_t0y = ty;
g_alb_s0 = g_alb_scroll;
} else {
float dy = ty - g_alb_t0y;
if (!g_alb_dragged && (dy > 14.0f || dy < -14.0f))
g_alb_dragged = 1;
if (g_alb_dragged) {
float ns = g_alb_s0 - dy;
g_alb_scroll = g_alb_target =
ns < 0 ? 0 : (ns > maxs ? maxs : ns);
}
}
g_alb_tx = tx;
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 */
} else if (!g_alb_dragged) {
float cy = g_alb_ty + g_alb_scroll - ALB_TOP;
int row = (int)(cy / ALB_ROWH);
float inrow = cy - row * ALB_ROWH;
float cx = g_alb_tx - ALB_X0;
int col = (int)(cx / (ALB_CW + ALB_GAP));
float incol = cx - col * (ALB_CW + ALB_GAP);
int a = row * ALB_COLS + col;
if (cy >= 0 && cx >= 0 && col < ALB_COLS &&
incol <= ALB_CW && inrow <= ALB_COVH + 50 &&
a < n) {
g_alb_sel = a;
action = ALB_OPEN;
}
}
}
/* keep the selected card on screen (d-pad), with an eased scroll */
if (!g_alb_touch) {
float sx, sy;
alb_card_xy(g_alb_sel, &sx, &sy);
if (sy - g_alb_target < ALB_TOP)
g_alb_target = sy - ALB_TOP;
if (sy + ALB_ROWH - g_alb_target > SCREEN_H)
g_alb_target = sy + ALB_ROWH - SCREEN_H;
if (g_alb_target < 0)
g_alb_target = 0;
if (g_alb_target > maxs)
g_alb_target = maxs;
g_alb_scroll += (g_alb_target - g_alb_scroll) * 0.3f;
if (fabsf(g_alb_target - g_alb_scroll) < 0.5f)
g_alb_scroll = g_alb_target;
}
int first_row = (int)((g_alb_scroll) / ALB_ROWH);
int last_row = (int)((g_alb_scroll + SCREEN_H - ALB_TOP) / ALB_ROWH);
int first = first_row * ALB_COLS;
int last = (last_row + 1) * ALB_COLS - 1;
if (last >= n)
last = n - 1;
albums_pump_covers(first, last);
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++) {
float x, y;
alb_card_xy(a, &x, &y);
draw_album_card(a, x, y - g_alb_scroll, a == g_alb_sel);
}
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);
}
vita2d_end_drawing();
vita2d_swap_buffers();
return action;
}
/* worker threads (the main thread only animates a throbber meanwhile) */
static int albums_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
fetch_albums();
__sync_synchronize();
g_album_fetching = 0;
return 0;
}
static int album_open_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
int r = fetch_album_assets(g_album_open);
__sync_synchronize();
g_album_assets_result = r;
g_album_assets_done = 1;
return 0;
}
+289
View File
@@ -378,4 +378,293 @@ static int meta_worker(SceSize args, void *argp)
return 0; return 0;
} }
/* ------------------------------------------------------------------ */
/* albums (read-only) */
/* */
/* GET /api/albums returns a flat JSON array of AlbumResponseDto */
/* (id, albumName, albumThumbnailAssetId, assetCount, shared, ...). */
/* An album's photos come from POST /api/search/metadata with */
/* {"albumIds":[id],"page":N,"size":...,"withExif":true}. The plain */
/* albumIds form pages with assets.nextPage on every server version */
/* that has search/metadata; newer servers (3.x) may answer with */
/* assets.nextCursor instead, which is followed too. */
/* ------------------------------------------------------------------ */
#define ALBUM_PAGE_SIZE 250
#define ALBUM_MAX_PAGES 200 /* 50k photos: a runaway-loop guard */
/* raw text of `key` anywhere in the document (first match), "" for null.
* json_get only looks at top-level keys; paging info is nested in "assets". */
static int json_find_any(const char *js, size_t len, const char *key,
char *out, size_t outlen)
{
jsmn_parser p;
jsmn_init(&p);
int n = jsmn_parse(&p, js, len, NULL, 0);
if (n <= 0)
return 0;
jsmntok_t *t = malloc(sizeof(*t) * n);
if (!t)
return 0;
jsmn_init(&p);
n = jsmn_parse(&p, js, len, t, n);
int kl = (int)strlen(key), got = 0;
for (int i = 1; i + 1 < n; i++) {
if (t[i].type == JSMN_STRING && t[i].size == 1 &&
t[i].end - t[i].start == kl &&
!strncmp(js + t[i].start, key, kl)) {
int vl = t[i + 1].end - t[i + 1].start;
if (t[i + 1].type == JSMN_PRIMITIVE && vl == 4 &&
!strncmp(js + t[i + 1].start, "null", 4))
vl = 0;
if (vl >= (int)outlen)
vl = (int)outlen - 1;
memcpy(out, js + t[i + 1].start, vl);
out[vl] = '\0';
got = 1;
break;
}
}
free(t);
return got;
}
static int json_true(const char *js, const jsmntok_t *v)
{
return v->type == JSMN_PRIMITIVE && v->end - v->start == 4 &&
!strncmp(js + v->start, "true", 4);
}
/* parse a GET /api/albums response into the g_album_* arrays, preserving the
* server's ordering. returns the number of albums parsed, or -1 on error. */
static int parse_albums(const char *js, size_t len)
{
jsmn_parser p;
jsmn_init(&p);
int nt = jsmn_parse(&p, js, len, NULL, 0);
if (nt <= 0)
return -1;
jsmntok_t *t = malloc(sizeof(*t) * nt);
if (!t)
return -1;
jsmn_init(&p);
nt = jsmn_parse(&p, js, len, t, nt);
if (nt <= 0 || t[0].type != JSMN_ARRAY) {
free(t);
return -1;
}
g_album_len = 0;
int cur = -1, cur_obj = -1;
for (int i = 1; i < nt; i++) {
if (t[i].type == JSMN_OBJECT && t[i].parent == 0) {
if (!grow_albums(g_album_len + 1))
break; /* out of memory: keep what we have */
cur = g_album_len++;
cur_obj = i;
g_album_id[cur][0] = '\0';
g_album_name[cur][0] = '\0';
g_album_thumb[cur][0] = '\0';
g_album_nassets[cur] = 0;
g_album_shared[cur] = 0;
g_album_has_link[cur] = 0;
g_album_cover[cur] = -1;
continue;
}
if (cur < 0 || t[i].type != JSMN_STRING || t[i].size != 1 ||
i + 1 >= nt || t[i].parent != cur_obj)
continue;
int kl = t[i].end - t[i].start;
const char *k = js + t[i].start;
jsmntok_t *v = &t[i + 1];
int vl = v->end - v->start;
if (kl == 2 && !strncmp(k, "id", 2) && v->type == JSMN_STRING &&
vl > 0 && vl < (int)sizeof(g_album_id[0]))
snprintf(g_album_id[cur], sizeof(g_album_id[0]), "%.*s",
vl, js + v->start);
else if (kl == 9 && !strncmp(k, "albumName", 9) &&
v->type == JSMN_STRING)
/* names may be cut mid UTF-8 sequence; draw_text copes */
snprintf(g_album_name[cur], sizeof(g_album_name[0]),
"%.*s", vl, js + v->start);
else if (kl == 21 && !strncmp(k, "albumThumbnailAssetId", 21) &&
v->type == JSMN_STRING && vl > 0 &&
vl < (int)sizeof(g_album_thumb[0]))
/* an empty album's null cover is a JSMN_PRIMITIVE */
snprintf(g_album_thumb[cur], sizeof(g_album_thumb[0]),
"%.*s", vl, js + v->start);
else if (kl == 10 && !strncmp(k, "assetCount", 10))
g_album_nassets[cur] = atoi(js + v->start);
else if (kl == 6 && !strncmp(k, "shared", 6))
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);
}
free(t);
/* drop entries without an id (would break the member fetch) */
int w = 0;
for (int r = 0; r < g_album_len; r++) {
if (!g_album_id[r][0])
continue;
if (w != r) {
memcpy(g_album_id[w], g_album_id[r], sizeof(g_album_id[0]));
memcpy(g_album_name[w], g_album_name[r], sizeof(g_album_name[0]));
memcpy(g_album_thumb[w], g_album_thumb[r], sizeof(g_album_thumb[0]));
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];
g_album_cover[w] = g_album_cover[r];
}
w++;
}
g_album_len = w;
return g_album_len;
}
/* fetch the album list (GET /api/albums) into the g_album_* arrays.
* Sets g_album_state to 1 on success / -1 on failure. Worker thread. */
static void fetch_albums(void)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/albums", g_server);
membuf buf;
long code;
CURLcode res = http_request(url, NULL, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("albums: curl %d http %ld: %.200s", res, code,
buf.data ? buf.data : "(no body)");
free(buf.data);
__sync_synchronize();
g_album_state = -1;
return;
}
int n = parse_albums(buf.data, buf.size);
size_t sz = buf.size;
free(buf.data);
if (n < 0) {
log_line("albums: JSON parse failed (%d bytes)", (int)sz);
__sync_synchronize();
g_album_state = -1;
return;
}
log_line("albums: %d fetched", n);
__sync_synchronize();
g_album_state = 1;
}
/* fetch every member of album `a` into g_album_idx (server assets; new ones
* are appended to g_asset_* like smart-search results). Worker thread: the
* main thread only draws a throbber while this runs (same contract as
* run_smart_search). Returns the member count, or -1 on failure. */
static int fetch_album_assets(int a)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/search/metadata", g_server);
g_album_idx_count = 0;
g_album_assets_loaded = 0;
static char (*ids)[40];
static int ids_cap;
int page = 1;
char cursor[160] = "";
for (int guard = 0; guard < ALBUM_MAX_PAGES; guard++) {
char body[420];
if (cursor[0])
snprintf(body, sizeof(body),
"{\"albumIds\":[\"%s\"],\"size\":%d,"
"\"cursor\":\"%s\",\"withExif\":true}",
g_album_id[a], ALBUM_PAGE_SIZE, cursor);
else
snprintf(body, sizeof(body),
"{\"albumIds\":[\"%s\"],\"size\":%d,"
"\"page\":%d,\"withExif\":true}",
g_album_id[a], ALBUM_PAGE_SIZE, page);
membuf buf;
long code;
CURLcode res = http_request(url, body, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("album '%s' page %d: curl %d http %ld: %.200s",
g_album_name[a], guard + 1, res, code,
buf.data ? buf.data : "(no body)");
free(buf.data);
return g_album_idx_count > 0 ? g_album_idx_count : -1;
}
char next_page[16] = "", next_cursor[160] = "";
json_find_any(buf.data, buf.size, "nextPage", next_page,
sizeof(next_page));
json_find_any(buf.data, buf.size, "nextCursor", next_cursor,
sizeof(next_cursor));
int before = g_asset_count;
char err[160];
int added = parse_assets(buf.data, buf.size, err, sizeof(err));
free(buf.data);
if (added < 0) {
log_line("album '%s' page %d: parse failed: %s",
g_album_name[a], guard + 1, err);
return g_album_idx_count > 0 ? g_album_idx_count : -1;
}
/* capture this page's ids before dedup compacts the tail, then
* resolve each one to its surviving g_asset index */
int raw = g_asset_count - before;
if (raw > ids_cap) {
void *np = realloc(ids, sizeof(ids[0]) * raw);
if (!np)
return -1;
ids = np;
ids_cap = raw;
}
for (int i = 0; i < raw; i++)
memcpy(ids[i], g_asset_ids[before + i], sizeof(ids[0]));
/* an id already present as a bare placeholder (an album cover or
* map-gallery entry from asset_for_id: no date/aspect yet) keeps
* its index through dedup, so hand it this page's metadata */
for (int i = before; i < g_asset_count; i++)
for (int j = 0; j < before; j++)
if (g_asset_dates[j][0] == '\0' &&
!strcmp(g_asset_ids[j], g_asset_ids[i])) {
memcpy(g_asset_dates[j], g_asset_dates[i],
sizeof(g_asset_dates[0]));
g_asset_ratio[j] = g_asset_ratio[i];
g_asset_rot[j] = g_asset_rot[i];
g_asset_is_video[j] = g_asset_is_video[i];
break;
}
dedup_new_assets(before);
for (int i = 0; i < raw; i++) {
int idx = -1;
for (int j = g_asset_count - 1; j >= 0; j--)
if (!strcmp(g_asset_ids[j], ids[i])) {
idx = j;
break;
}
if (idx < 0 || !grow_album_idx(g_album_idx_count + 1))
continue;
g_album_idx[g_album_idx_count++] = idx;
}
g_album_assets_loaded = g_album_idx_count;
if (next_cursor[0] && next_cursor[0] != '"') {
/* raw token text: strip nothing (jsmn excludes quotes) */
snprintf(cursor, sizeof(cursor), "%s", next_cursor);
} else if (next_page[0] && atoi(next_page) > page) {
page = atoi(next_page);
cursor[0] = '\0';
} else {
break; /* last page */
}
if (raw == 0)
break;
}
log_line("album '%s': %d member(s)", g_album_name[a], g_album_idx_count);
return g_album_idx_count;
}
+261 -10
View File
@@ -9,7 +9,8 @@
/* main */ /* main */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS, MODE_MAP }; enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS, MODE_MAP,
MODE_ALBUMS };
/* the whole boot workload (auto sign-in, first library page, local media scan) /* the whole boot workload (auto sign-in, first library page, local media scan)
* runs on this one thread so the main thread can animate a single, continuous * runs on this one thread so the main thread can animate a single, continuous
@@ -19,6 +20,74 @@ enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS, MODE_MAP };
static char g_boot_status[48] = "Loading..."; static char g_boot_status[48] = "Loading...";
static volatile int g_boot_done; static volatile int g_boot_done;
static volatile int g_boot_login_failed; static volatile int g_boot_login_failed;
#ifdef VIMM_TEST_INPUT
/* TEST BUILDS ONLY (-DVIMM_TEST_INPUT): scripted buttons for emulator runs
* where synthetic key events can't reach Vita3K. Lines in
* ux0:/data/vitaimmich/test_input.txt: "x", "down:3" (repeat), "wait:60"
* (frames), "hold_r:30". The file is consumed (deleted) once read. */
static unsigned int test_input_buttons(void)
{
static unsigned int queue_btn[256];
static int queue_len[256], qn, qi, left;
static unsigned int cur;
static unsigned int frames;
frames++;
if (left > 0) {
left--;
return cur;
}
if (qi >= qn && frames % 20 == 0) {
FILE *f = fopen("ux0:/data/vitaimmich/test_input.txt", "r");
if (f) {
char line[64];
qn = qi = 0;
while (fgets(line, sizeof(line), f) && qn < 250) {
char name[32] = "";
int n = 1;
if (sscanf(line, "%31[a-z_]:%d", name, &n) < 1)
continue;
struct { const char *k; unsigned int b; } map[] = {
{ "x", SCE_CTRL_CROSS }, { "o", SCE_CTRL_CIRCLE },
{ "tri", SCE_CTRL_TRIANGLE }, { "sq", SCE_CTRL_SQUARE },
{ "up", SCE_CTRL_UP }, { "down", SCE_CTRL_DOWN },
{ "left", SCE_CTRL_LEFT }, { "right", SCE_CTRL_RIGHT },
{ "l", SCE_CTRL_LTRIGGER }, { "r", SCE_CTRL_RTRIGGER },
{ "sel", SCE_CTRL_SELECT }, { "start", SCE_CTRL_START },
};
if (!strcmp(name, "wait")) {
queue_btn[qn] = 0; queue_len[qn++] = n;
continue;
}
int hold = !strncmp(name, "hold_", 5);
const char *key = hold ? name + 5 : name;
for (unsigned m = 0; m < sizeof(map) / sizeof(map[0]); m++)
if (!strcmp(key, map[m].k)) {
if (hold) {
queue_btn[qn] = map[m].b; queue_len[qn++] = n;
} else {
for (int r = 0; r < n && qn < 248; r++) {
queue_btn[qn] = map[m].b; queue_len[qn++] = 4;
queue_btn[qn] = 0; queue_len[qn++] = 6;
}
}
}
}
fclose(f);
sceIoRemove("ux0:/data/vitaimmich/test_input.txt");
log_line("test input: %d steps", qn);
}
}
if (qi < qn) {
cur = queue_btn[qi];
left = queue_len[qi] - 1;
qi++;
return cur;
}
cur = 0;
return 0;
}
#endif
static int boot_thread(SceSize args, void *argp) static int boot_thread(SceSize args, void *argp)
{ {
(void)args; (void)argp; (void)args; (void)argp;
@@ -114,6 +183,14 @@ static void reset_for_account_change(void)
g_search_count = 0; g_search_count = 0;
g_gallery_active = 0; g_gallery_active = 0;
g_gallery_count = 0; g_gallery_count = 0;
g_album_active = 0; /* re-fetch the new account's albums */
g_album_open = -1;
g_album_idx_count = 0;
g_album_len = 0;
g_album_state = 0;
g_album_fetching = 0;
g_alb_sel = 0;
g_alb_scroll = g_alb_target = 0;
g_srv_state = 0; /* re-fetch the cloud page's server info */ g_srv_state = 0; /* re-fetch the cloud page's server info */
g_srv_fetching = 0; g_srv_fetching = 0;
g_map_state = 0; /* re-fetch the new account's map markers */ g_map_state = 0; /* re-fetch the new account's map markers */
@@ -339,6 +416,9 @@ int main(void)
for (;;) { for (;;) {
SceCtrlData pad; SceCtrlData pad;
sceCtrlPeekBufferPositive(0, &pad, 1); sceCtrlPeekBufferPositive(0, &pad, 1);
#ifdef VIMM_TEST_INPUT
pad.buttons |= test_input_buttons();
#endif
unsigned int pressed = pad.buttons & ~prev_buttons; unsigned int pressed = pad.buttons & ~prev_buttons;
prev_buttons = pad.buttons; prev_buttons = pad.buttons;
@@ -410,6 +490,7 @@ int main(void)
(pressed & SCE_CTRL_CIRCLE) != 0; (pressed & SCE_CTRL_CIRCLE) != 0;
int do_open_cloud = 0; /* tap on the cloud button */ int do_open_cloud = 0; /* tap on the cloud button */
int do_open_map = 0; /* tap on the map button */ int do_open_map = 0; /* tap on the map button */
int do_open_albums = 0; /* tap on the albums button */
/* SQUARE slides the top bar in/out (when not focused); /* SQUARE slides the top bar in/out (when not focused);
* while focused it drops focus back to the grid */ * while focused it drops focus back to the grid */
@@ -425,7 +506,7 @@ int main(void)
show_sel = 1; show_sel = 1;
if (bar_focus) { if (bar_focus) {
/* bar focused (1=field, 2=map, 3=cloud): left/right /* bar focused (1=field, 2=albums, 3=map, 4=cloud): left/right
* move across the elements, down or [] returns to * move across the elements, down or [] returns to
* the grid, X activates the focused element */ * the grid, X activates the focused element */
show_sel = 0; show_sel = 0;
@@ -440,14 +521,16 @@ int main(void)
} else { } else {
if ((nav & SCE_CTRL_LEFT) && bar_focus > 1) if ((nav & SCE_CTRL_LEFT) && bar_focus > 1)
bar_focus--; bar_focus--;
if ((nav & SCE_CTRL_RIGHT) && bar_focus < 3) if ((nav & SCE_CTRL_RIGHT) && bar_focus < 4)
bar_focus++; bar_focus++;
if (pressed & SCE_CTRL_CROSS) { if (pressed & SCE_CTRL_CROSS) {
if (bar_focus == 1) if (bar_focus == 1)
do_search_open = 1; do_search_open = 1;
else if (bar_focus == 2) else if (bar_focus == 2)
do_open_map = 1; do_open_albums = 1;
else if (bar_focus == 3) else if (bar_focus == 3)
do_open_map = 1;
else if (bar_focus == 4)
do_open_cloud = 1; do_open_cloud = 1;
} }
} }
@@ -494,7 +577,7 @@ int main(void)
* shows up (or the library ends). search results * shows up (or the library ends). search results
* aren't paginated, so skip the pull. */ * aren't paginated, so skip the pull. */
if (dir > 0 && !g_search_active && if (dir > 0 && !g_search_active &&
!g_gallery_active && can_pull) { !g_gallery_active && !g_album_active && can_pull) {
int guard = 0; int guard = 0;
while (g_next_page > 0 && guard++ < 10 && while (g_next_page > 0 && guard++ < 10 &&
!strncmp(disp_date(month_jump(sel, +1)), !strncmp(disp_date(month_jump(sel, +1)),
@@ -552,7 +635,7 @@ int main(void)
* screen: the grid stays up, the HUD's "+" already says * screen: the grid stays up, the HUD's "+" already says
* more is coming, and the fetch only blocks briefly */ * more is coming, and the fetch only blocks briefly */
if (!scrolling_fast && rl_idle >= 12 && !g_search_active && if (!scrolling_fast && rl_idle >= 12 && !g_search_active &&
!g_gallery_active && g_next_page > 0 && !g_gallery_active && !g_album_active && g_next_page > 0 &&
sel >= g_disp_count - COLS * 4) { sel >= g_disp_count - COLS * 4) {
if (fetch_page(0) > 0) if (fetch_page(0) > 0)
rebuild_keep_view(&sel, &scroll, rebuild_keep_view(&sel, &scroll,
@@ -565,7 +648,7 @@ int main(void)
* across the relayout */ * across the relayout */
uint64_t pnow = sceKernelGetProcessTimeWide(); uint64_t pnow = sceKernelGetProcessTimeWide();
if (!scrolling_fast && !touch_active && !g_search_active && if (!scrolling_fast && !touch_active && !g_search_active &&
!g_gallery_active && !g_gallery_active && !g_album_active &&
pnow - last_poll > 30ULL * 1000 * 1000) { pnow - last_poll > 30ULL * 1000 * 1000) {
last_poll = pnow; last_poll = pnow;
if (check_new_assets() > 0) if (check_new_assets() > 0)
@@ -631,14 +714,17 @@ int main(void)
* the clear chip clears an active * the clear chip clears an active
* search, the field opens the * search, the field opens the
* keyboard */ * keyboard */
if (touch_x > BTN2_CX - BTN_SZ / 2) { if (touch_x > BTN2_CX - BTN_SZ / 2 - BTN_GAP / 2) {
/* cloud button: open the /* cloud button: open the
* server/backup page */ * server/backup page */
do_open_cloud = 1; do_open_cloud = 1;
} else if (touch_x > BAR_PX + BAR_W) { } else if (touch_x > BTN1_CX - BTN_SZ / 2 - BTN_GAP / 2) {
/* map button: open the map */ /* map button: open the map */
do_open_map = 1; do_open_map = 1;
} else if (g_search_active && } else if (touch_x > BAR_PX + BAR_W) {
/* albums button */
do_open_albums = 1;
} else if ((g_search_active || g_album_active) &&
touch_x > BAR_CLEAR_CX - 16) touch_x > BAR_CLEAR_CX - 16)
do_search_clear = 1; do_search_clear = 1;
else else
@@ -692,6 +778,46 @@ int main(void)
(do_open_cloud || do_open_map || do_search_open)) (do_open_cloud || do_open_map || do_search_open))
gallery_close(); gallery_close();
/* inside an album, O (or the bar's chip) returns to
* the album list; any other bar action leaves it */
if (g_album_active &&
((pressed & SCE_CTRL_CIRCLE) || do_search_clear ||
do_open_albums)) {
g_album_active = 0;
rebuild_display();
mode = MODE_ALBUMS;
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
detail_idx = -1;
touch_active = touch_on_bar = 0;
for (int k = 0; k < 2; k++) {
if (pf_tex[k]) {
vita2d_wait_rendering_done();
vita2d_free_texture(pf_tex[k]);
pf_tex[k] = NULL;
}
pf_d[k] = -1;
}
continue;
}
if (g_album_active &&
(do_open_cloud || do_open_map || do_search_open)) {
g_album_active = 0;
rebuild_display();
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
}
if (do_open_albums) {
if (g_gallery_active)
gallery_close();
mode = MODE_ALBUMS;
bar_focus = 0;
touch_active = touch_on_bar = 0;
continue;
}
if (do_open_cloud) { if (do_open_cloud) {
mode = MODE_CLOUD; mode = MODE_CLOUD;
continue; continue;
@@ -945,6 +1071,10 @@ int main(void)
draw_centered(SCREEN_H / 2, draw_centered(SCREEN_H / 2,
RGBA8(160, 160, 165, 255), RGBA8(160, 160, 165, 255),
"Loading photos..."); "Loading photos...");
else if (g_album_active && g_disp_count == 0)
draw_centered(SCREEN_H / 2,
RGBA8(160, 160, 165, 255),
"This album is empty");
/* pinned Immich-style search bar, slid up by bar_hidden /* pinned Immich-style search bar, slid up by bar_hidden
* (Square toggles it); covers items up to its lower edge */ * (Square toggles it); covers items up to its lower edge */
@@ -1804,6 +1934,127 @@ pf_skip:
draw_hud("Up/Down scroll O back"); draw_hud("Up/Down scroll O back");
vita2d_end_drawing(); vita2d_end_drawing();
vita2d_swap_buffers(); vita2d_swap_buffers();
} else if (mode == MODE_ALBUMS) {
/* album list: fetched on a worker thread the first time
* (or after /\ refresh) while a throbber spins */
if (g_album_state == 0 || g_album_fetching) {
if (!g_album_fetching) {
g_album_fetching = 1;
SceUID at = sceKernelCreateThread("albums",
albums_thread, 0x10000100,
128 * 1024, 0, 0, NULL);
if (at >= 0)
sceKernelStartThread(at, 0, NULL);
else {
fetch_albums(); /* fallback */
g_album_fetching = 0;
}
}
vita2d_start_drawing();
vita2d_clear_screen();
draw_throbber(SCREEN_W / 2.0f,
SCREEN_H / 2.0f - 16.0f, 22.0f, frame);
draw_centered(SCREEN_H / 2 + 36,
RGBA8(200, 200, 200, 255),
"Loading albums...");
vita2d_end_drawing();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
continue;
}
if (g_album_state == -1) {
vita2d_start_drawing();
vita2d_clear_screen();
draw_centered(SCREEN_H / 2 - 10,
RGBA8(220, 120, 120, 255),
"Albums unavailable (see log.txt)");
draw_centered(SCREEN_H / 2 + 24,
RGBA8(160, 160, 165, 255),
"O back /\\ retry");
if (pressed & SCE_CTRL_CIRCLE)
mode = MODE_GRID;
if (pressed & SCE_CTRL_TRIANGLE)
g_album_state = 0;
vita2d_end_drawing();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
continue;
}
int act = albums_page(pressed, nav);
sceDisplayWaitVblankStart();
if (act == ALB_BACK) {
albums_release_covers();
mode = MODE_GRID;
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
} else if (act == ALB_REFRESH) {
albums_release_covers();
g_album_state = 0;
} else if (act == ALB_OPEN && g_alb_sel < g_album_len) {
/* fetch the members on a worker thread; the main
* thread only animates the throbber meanwhile */
albums_release_covers();
for (int i = 0; i < 300 && g_req_state == REQ_PENDING; i++)
sceKernelDelayThread(10 * 1000);
if (g_req_state == REQ_DONE)
consume_worker_result();
g_search_active = 0;
if (g_gallery_active)
gallery_close();
g_album_open = g_alb_sel;
g_album_assets_done = 0;
SceUID ot = sceKernelCreateThread("album_open",
album_open_thread, 0x10000100,
128 * 1024, 0, 0, NULL);
if (ot >= 0) {
sceKernelStartThread(ot, 0, NULL);
unsigned int sf = 0;
while (!g_album_assets_done) {
char cap[160];
snprintf(cap, sizeof(cap),
"Loading \"%.48s\"... %d / %d",
g_album_name[g_album_open],
g_album_assets_loaded,
g_album_nassets[g_album_open]);
vita2d_start_drawing();
vita2d_clear_screen();
draw_throbber(SCREEN_W / 2.0f,
SCREEN_H / 2.0f - 16.0f, 22.0f, sf++);
draw_centered(SCREEN_H / 2 + 36,
RGBA8(200, 200, 200, 255), cap);
vita2d_end_drawing();
vita2d_swap_buffers();
}
} else {
g_album_assets_result =
fetch_album_assets(g_album_open);
}
if (g_album_assets_result < 0) {
log_line("album open failed");
continue; /* stay on the list */
}
g_album_active = 1;
rebuild_display();
for (int k = 0; k < 2; k++) {
if (pf_tex[k]) {
vita2d_wait_rendering_done();
vita2d_free_texture(pf_tex[k]);
pf_tex[k] = NULL;
}
pf_d[k] = -1;
}
mode = MODE_GRID;
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
show_sel = 1;
bar_shown = 1;
bar_focus = 0;
detail_idx = -1;
}
continue;
} else if (mode == MODE_MAP) { } else if (mode == MODE_MAP) {
/* geotag map, opened from the map button on the search /* geotag map, opened from the map button on the search
* bar. markers load on a worker thread the first time; * bar. markers load on a worker thread the first time;
+20
View File
@@ -199,6 +199,26 @@ static void rebuild_display(void)
return; return;
} }
/* album mode: the open album's members (server assets), date-grouped
* like search results; placeholders without a date are skipped */
if (g_album_active) {
if (!grow_disp(g_album_idx_count > 0 ? g_album_idx_count : 1))
return;
int n = 0;
for (int k = 0; k < g_album_idx_count; k++) {
int idx = g_album_idx[k];
if (g_asset_dates[idx][0] == '\0')
continue;
g_disp[n].src = SRC_SERVER;
g_disp[n].idx = idx;
n++;
}
g_disp_count = n;
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
layout_grid();
return;
}
/* search mode: the merged timeline is replaced by the result set /* search mode: the merged timeline is replaced by the result set
* (server assets only, date-grouped); local media isn't folded in */ * (server assets only, date-grouped); local media isn't folded in */
if (g_search_active) { if (g_search_active) {
+1
View File
@@ -158,6 +158,7 @@ static char (*g_tex_err)[160];
#include "display.c" /* merged server+local timeline + grid layout */ #include "display.c" /* merged server+local timeline + grid layout */
#include "ui.c" /* grid/detail/search/login/cloud screens */ #include "ui.c" /* grid/detail/search/login/cloud screens */
#include "map.c" /* geotag map: markers, clustering, projection */ #include "map.c" /* geotag map: markers, clustering, projection */
#include "albums.c" /* album list page + album open/fetch threads */
#include "video.c" /* video download + SceAvPlayer playback */ #include "video.c" /* video download + SceAvPlayer playback */
#include "sync.c" /* local scan/hash/check/upload + sync overview */ #include "sync.c" /* local scan/hash/check/upload + sync overview */
#include "app.c" /* main(): init, event loop, threads */ #include "app.c" /* main(): init, event loop, threads */
+69
View File
@@ -121,6 +121,46 @@ static char g_srv_version[40]; /* "v1.119.0" */
static volatile int g_pause_bg; /* request the sync thread to idle */ static volatile int g_pause_bg; /* request the sync thread to idle */
static volatile int g_sync_idle; /* sync thread acknowledges it idled */ static volatile int g_sync_idle; /* sync thread acknowledges it idled */
/* ---- albums (read-only browsing) ------------------------------------ */
/* The album LIST lives in the g_album_* arrays, filled by GET /api/albums on
* a worker thread (the main thread only draws a throbber meanwhile). Each
* entry keeps the server's own ordering.
*
* Opening an album switches the grid to that album's assets, exactly like the
* smart-search result set: g_album_active marks the filter and g_album_idx
* holds indices into the g_asset_* arrays (which only ever grow). The members
* are fetched page by page with /api/search/metadata (withExif, so they carry
* date + aspect and drop straight into month sections).
*
* g_album_state: 0 not fetched yet, 1 ok, -1 failed. g_album_fetching: a
* worker thread is fetching the list right now. */
static char (*g_album_id)[40]; /* album uuid */
static char (*g_album_name)[64]; /* display name */
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 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 int g_album_active; /* grid is showing one album's assets */
static int g_album_open = -1; /* index into g_album_* being shown */
static int *g_album_idx; /* g_asset indices of its members */
static int g_album_idx_count;
static int g_album_idx_cap;
/* open-album fetch (worker thread): done flag, result, members so far */
static volatile int g_album_assets_done;
static volatile int g_album_assets_result;
static volatile int g_album_assets_loaded;
/* each album's cover as a g_asset index (asset_for_id), so the shared
* thumbnail worker loads it into g_thumb[] like any grid thumbnail.
* -1 = no cover (empty album) or not resolved yet. */
static int *g_album_cover;
/* Texture recycling pool. Freeing a texture unmaps its memblock, and the /* Texture recycling pool. Freeing a texture unmaps its memblock, and the
* GPU side (notably Vita3K's texture cache, which re-reads guest memory of * GPU side (notably Vita3K's texture cache, which re-reads guest memory of
* cached textures at its own pace) may still touch it afterwards — freeing * cached textures at its own pace) may still touch it afterwards — freeing
@@ -249,6 +289,35 @@ static int grow_gallery(int need)
return 1; return 1;
} }
/* ensure the album arrays hold at least `need` entries */
static int grow_albums(int need)
{
int c = g_album_cap;
if (need <= c)
return 1;
if (!GROW(g_album_id, c, need) ||
!GROW(g_album_name, c, need) ||
!GROW(g_album_thumb, c, need) ||
!GROW(g_album_nassets, c, need) ||
!GROW(g_album_shared, c, need) ||
!GROW(g_album_has_link, c, need) ||
!(g_album_cap = GROW(g_album_cover, c, need)))
return 0;
return 1;
}
/* ensure an open album's member index array holds at least `need` entries */
static int grow_album_idx(int need)
{
if (need <= g_album_idx_cap)
return 1;
int cap = GROW(g_album_idx, g_album_idx_cap, need);
if (!cap)
return 0;
g_album_idx_cap = cap;
return 1;
}
static void log_line(const char *fmt, ...) static void log_line(const char *fmt, ...)
{ {
FILE *f = fopen(LOG_PATH, "a"); FILE *f = fopen(LOG_PATH, "a");
+51 -14
View File
@@ -270,12 +270,33 @@ static int cov_play(float px, float py, int s)
s * 0.36f, s * 0.73f, s * 0.76f, s * 0.5f); s * 0.36f, s * 0.73f, s * 0.76f, s * 0.5f);
} }
/* a photo-stack glyph (albums): an outlined photo with a small mountain and
* sun, in front of the top/right edges of a second photo offset behind it */
static int cov_album(float px, float py, int s)
{
float W = GLYPH_W;
float fx0 = s * 0.10f, fy0 = s * 0.30f, fx1 = s * 0.76f, fy1 = s * 0.88f;
int in_front = px >= fx0 && px <= fx1 && py >= fy0 && py <= fy1;
int front_line = in_front && !(px >= fx0 + W && px <= fx1 - W &&
py >= fy0 + W && py <= fy1 - W);
int mountain = in_tri(px, py, fx0 + W, fy1 - W, s * 0.36f, s * 0.56f,
s * 0.62f, fy1 - W);
float sx = s * 0.57f, sy = s * 0.45f, sr = s * 0.065f;
int sun = (px - sx) * (px - sx) + (py - sy) * (py - sy) <= sr * sr;
float bx0 = s * 0.24f, by0 = s * 0.12f, bx1 = s * 0.92f, by1 = s * 0.72f;
int back_line = ((py >= by0 && py <= by0 + W && px >= bx0 && px <= bx1) ||
(px >= bx1 - W && px <= bx1 && py >= by0 && py <= by1)) &&
!(px >= fx0 - 2.0f && px <= fx1 + 2.0f &&
py >= fy0 - 2.0f && py <= fy1 + 2.0f);
return front_line || mountain || sun || back_line;
}
/* cache one texture per glyph; all are drawn at a fixed size each frame */ /* cache one texture per glyph; all are drawn at a fixed size each frame */
static vita2d_texture *icon_tex(int which, int s) static vita2d_texture *icon_tex(int which, int s)
{ {
static vita2d_texture *cache[9]; static vita2d_texture *cache[10];
static int cs[9]; static int cs[10];
if (which < 0 || which > 8) if (which < 0 || which > 9)
return NULL; return NULL;
if (cache[which] && cs[which] == s) if (cache[which] && cs[which] == s)
return cache[which]; return cache[which];
@@ -291,7 +312,8 @@ static vita2d_texture *icon_tex(int which, int s)
which == 4 ? cov_sq : which == 4 ? cov_sq :
which == 5 ? cov_cross : which == 5 ? cov_cross :
which == 6 ? cov_circle : which == 6 ? cov_circle :
which == 7 ? cov_play : cov_cloud_line; which == 7 ? cov_play :
which == 8 ? cov_cloud_line : cov_album;
cache[which] = bake_icon(s, fn); cache[which] = bake_icon(s, fn);
cs[which] = s; cs[which] = s;
return cache[which]; return cache[which];
@@ -305,6 +327,7 @@ static vita2d_texture *icon_tex(int which, int s)
#define ICON_CIRCLE 6 #define ICON_CIRCLE 6
#define ICON_PLAY 7 #define ICON_PLAY 7
#define ICON_CLOUD_LINE 8 #define ICON_CLOUD_LINE 8
#define ICON_ALBUM 9
/* a DualShock-style face button: a dark disc (subtle lighter rim) centred at /* a DualShock-style face button: a dark disc (subtle lighter rim) centred at
* (cx,cy) with the coloured glyph on top. each glyph keeps a per-call size. */ * (cx,cy) with the coloured glyph on top. each glyph keeps a per-call size. */
@@ -369,19 +392,20 @@ static void draw_ps_button(float cx, float cy, float d, int icon,
vita2d_draw_texture_tint(g, cx - gs / 2.0f, cy - gs / 2.0f, tint); vita2d_draw_texture_tint(g, cx - gs / 2.0f, cy - gs / 2.0f, tint);
} }
/* pixel geometry of the search pill + the two buttons on its right, shared /* pixel geometry of the search pill + the three buttons on its right
* by the drawer and the tap test */ * (albums, map, cloud), shared by the drawer and the tap test */
#define BAR_M 12.0f /* left/right margin */ #define BAR_M 12.0f /* left/right margin */
#define BAR_Y 8.0f /* top of the pill */ #define BAR_Y 8.0f /* top of the pill */
#define BAR_H 40.0f /* pill height */ #define BAR_H 40.0f /* pill height */
#define BAR_R 12.0f /* corner radius */ #define BAR_R 12.0f /* corner radius */
#define BTN_SZ 40.0f /* button (rounded square) size */ #define BTN_SZ 40.0f /* button (rounded square) size */
#define BTN_GAP 10.0f /* gap between the two buttons */ #define BTN_GAP 10.0f /* gap between the buttons */
#define BTN2_CX ((float)SCREEN_W - BAR_M - BTN_SZ / 2) /* cloud (rightmost) */ #define BTN2_CX ((float)SCREEN_W - BAR_M - BTN_SZ / 2) /* cloud (rightmost) */
#define BTN1_CX (BTN2_CX - BTN_SZ - BTN_GAP) /* map */ #define BTN1_CX (BTN2_CX - BTN_SZ - BTN_GAP) /* map */
#define BTN0_CX (BTN1_CX - BTN_SZ - BTN_GAP) /* albums */
#define BTN_CY (BAR_Y + BAR_H / 2) #define BTN_CY (BAR_Y + BAR_H / 2)
#define BAR_PX (BAR_M + 42.0f) /* pill left x (logo to its left) */ #define BAR_PX (BAR_M + 42.0f) /* pill left x (logo to its left) */
#define BAR_W (BTN1_CX - BTN_SZ / 2 - BTN_GAP - BAR_PX) /* pill width */ #define BAR_W (BTN0_CX - BTN_SZ / 2 - BTN_GAP - BAR_PX) /* pill width */
#define BAR_CLEAR_CX (BAR_PX + BAR_W - 22.0f) /* centre of the clear chip */ #define BAR_CLEAR_CX (BAR_PX + BAR_W - 22.0f) /* centre of the clear chip */
/* one future-feature button: rounded-square background + a centred AA glyph */ /* one future-feature button: rounded-square background + a centred AA glyph */
@@ -399,7 +423,7 @@ static void draw_bar_button(float cx, float cy, int icon)
} }
/* draw the bar shifted vertically by `yoff` (Square slides it out of view); /* draw the bar shifted vertically by `yoff` (Square slides it out of view);
* `focus` highlights the focused element (1=field, 2=map, 3=cloud) */ * `focus` highlights the focused element (1=field, 2=albums, 3=map, 4=cloud) */
static void draw_search_bar(float yoff, int focus) static void draw_search_bar(float yoff, int focus)
{ {
unsigned int field = RGBA8(38, 38, 42, 255); unsigned int field = RGBA8(38, 38, 42, 255);
@@ -433,21 +457,34 @@ static void draw_search_bar(float yoff, int focus)
cy - isz / 2.0f, cy - isz / 2.0f,
RGBA8(190, 190, 196, 255)); RGBA8(190, 190, 196, 255));
/* focus rings behind the placeholder buttons */ /* focus rings behind the buttons */
if (focus == 2 || focus == 3) { if (focus >= 2 && focus <= 4) {
vita2d_texture *r = rounded_mask_tex((int)BTN_SZ + 6, vita2d_texture *r = rounded_mask_tex((int)BTN_SZ + 6,
(int)BTN_SZ + 6, 14.0f); (int)BTN_SZ + 6, 14.0f);
float fcx = (focus == 2) ? BTN1_CX : BTN2_CX; float fcx = (focus == 2) ? BTN0_CX :
(focus == 3) ? BTN1_CX : BTN2_CX;
if (r) if (r)
vita2d_draw_texture_tint(r, fcx - (BTN_SZ + 6) / 2, vita2d_draw_texture_tint(r, fcx - (BTN_SZ + 6) / 2,
cy - (BTN_SZ + 6) / 2, hi); cy - (BTN_SZ + 6) / 2, hi);
} }
/* placeholder buttons (map, cloud) for future features */ draw_bar_button(BTN0_CX, cy, ICON_ALBUM);
draw_bar_button(BTN1_CX, cy, ICON_MAP); draw_bar_button(BTN1_CX, cy, ICON_MAP);
draw_bar_button(BTN2_CX, cy, ICON_CLOUD); draw_bar_button(BTN2_CX, cy, ICON_CLOUD);
float tx = BAR_PX + 44, ty = by + BAR_H - 13; float tx = BAR_PX + 44, ty = by + BAR_H - 13;
if (g_search_active && g_search_query[0]) { if (g_album_active && g_album_open >= 0 && g_album_open < g_album_len) {
/* inside an album: its name, and the O chip to go back */
char q[80];
snprintf(q, sizeof(q), "%.48s", g_album_name[g_album_open][0] ?
g_album_name[g_album_open] : "(untitled album)");
draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q);
int gs = 26;
vita2d_texture *og = icon_tex(ICON_CIRCLE, gs);
if (og)
vita2d_draw_texture_tint(og, BAR_CLEAR_CX - gs / 2.0f,
cy - gs / 2.0f,
RGBA8(235, 90, 85, 255));
} else if (g_search_active && g_search_query[0]) {
char q[64]; char q[64];
snprintf(q, sizeof(q), "%.40s", g_search_query); snprintf(q, sizeof(q), "%.40s", g_search_query);
draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q); draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q);