diff --git a/CMakeLists.txt b/CMakeLists.txt index 47bc797..e0ac5b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,10 @@ target_link_libraries(${PROJECT_NAME} jpeg z 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 SceGxm_stub SceSysmodule_stub diff --git a/Dockerfile b/Dockerfile index 5885fea..66ca2ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,46 @@ FROM vitasdk/vitasdk:latest -RUN apk add --no-cache \ - cmake \ - make \ - git \ - ca-certificates +# The published image has changed base distro over time (Alpine -> Ubuntu), so +# probe for the package manager instead of assuming `apk`. Recent images +# already ship cmake/make/git, in which case this layer is a no-op. +RUN if command -v apk >/dev/null 2>&1; then \ + 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 -# -f = force install (also works for first-time installs) -RUN vdpm -f \ - zlib \ - libpng \ - libjpeg-turbo \ - freetype \ - openssl \ - curl \ - zstd \ - vita2d +# vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm; -f = force install. +# +# NOTE 1: `openssl` (1.0.2) is deliberately NOT listed. The SDK's libcurl.a +# (curl 8.17) is prebuilt against OpenSSL 1.0.2, but the image ships +# openssl-1.1.1 as the installed default (a dependency of cpython3) and the two +# vdpm packages conflict, so `vdpm openssl` aborts and leaves libcurl +# referencing 1.0.2-only symbols (EVP_MD_CTX_create, SSLeay, +# SSLv23_client_method, ...) that 1.1.1 does not provide -> link failure. +# The RUN below overlays the matching 1.0.2 libs/headers from the tarball. +# +# 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 COPY . . -RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && \ - cmake --build /build --parallel "$(nproc)" +RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && cmake --build /build --parallel 4 # 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'"] \ No newline at end of file diff --git a/goals.md b/goals.md index d61947b..c23e061 100644 --- a/goals.md +++ b/goals.md @@ -2,7 +2,24 @@ 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 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 diff --git a/src/albums.c b/src/albums.c new file mode 100644 index 0000000..c02580a --- /dev/null +++ b/src/albums.c @@ -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; +} diff --git a/src/api.c b/src/api.c index 200aed7..3caa735 100644 --- a/src/api.c +++ b/src/api.c @@ -378,4 +378,293 @@ static int meta_worker(SceSize args, void *argp) 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; +} diff --git a/src/app.c b/src/app.c index eac5f5b..1c9c547 100644 --- a/src/app.c +++ b/src/app.c @@ -9,7 +9,8 @@ /* 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) * 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 volatile int g_boot_done; 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) { (void)args; (void)argp; @@ -114,6 +183,14 @@ static void reset_for_account_change(void) g_search_count = 0; g_gallery_active = 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_fetching = 0; g_map_state = 0; /* re-fetch the new account's map markers */ @@ -339,6 +416,9 @@ int main(void) for (;;) { SceCtrlData pad; sceCtrlPeekBufferPositive(0, &pad, 1); +#ifdef VIMM_TEST_INPUT + pad.buttons |= test_input_buttons(); +#endif unsigned int pressed = pad.buttons & ~prev_buttons; prev_buttons = pad.buttons; @@ -410,6 +490,7 @@ int main(void) (pressed & SCE_CTRL_CIRCLE) != 0; int do_open_cloud = 0; /* tap on the cloud 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); * while focused it drops focus back to the grid */ @@ -425,7 +506,7 @@ int main(void) show_sel = 1; 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 * the grid, X activates the focused element */ show_sel = 0; @@ -440,14 +521,16 @@ int main(void) } else { if ((nav & SCE_CTRL_LEFT) && bar_focus > 1) bar_focus--; - if ((nav & SCE_CTRL_RIGHT) && bar_focus < 3) + if ((nav & SCE_CTRL_RIGHT) && bar_focus < 4) bar_focus++; if (pressed & SCE_CTRL_CROSS) { if (bar_focus == 1) do_search_open = 1; else if (bar_focus == 2) - do_open_map = 1; + do_open_albums = 1; else if (bar_focus == 3) + do_open_map = 1; + else if (bar_focus == 4) do_open_cloud = 1; } } @@ -494,7 +577,7 @@ int main(void) * shows up (or the library ends). search results * aren't paginated, so skip the pull. */ if (dir > 0 && !g_search_active && - !g_gallery_active && can_pull) { + !g_gallery_active && !g_album_active && can_pull) { int guard = 0; while (g_next_page > 0 && guard++ < 10 && !strncmp(disp_date(month_jump(sel, +1)), @@ -552,7 +635,7 @@ int main(void) * screen: the grid stays up, the HUD's "+" already says * more is coming, and the fetch only blocks briefly */ 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) { if (fetch_page(0) > 0) rebuild_keep_view(&sel, &scroll, @@ -565,7 +648,7 @@ int main(void) * across the relayout */ uint64_t pnow = sceKernelGetProcessTimeWide(); if (!scrolling_fast && !touch_active && !g_search_active && - !g_gallery_active && + !g_gallery_active && !g_album_active && pnow - last_poll > 30ULL * 1000 * 1000) { last_poll = pnow; if (check_new_assets() > 0) @@ -631,14 +714,17 @@ int main(void) * the clear chip clears an active * search, the field opens the * keyboard */ - if (touch_x > BTN2_CX - BTN_SZ / 2) { + if (touch_x > BTN2_CX - BTN_SZ / 2 - BTN_GAP / 2) { /* cloud button: open the * server/backup page */ 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 */ 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) do_search_clear = 1; else @@ -692,6 +778,46 @@ int main(void) (do_open_cloud || do_open_map || do_search_open)) 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) { mode = MODE_CLOUD; continue; @@ -945,6 +1071,10 @@ int main(void) draw_centered(SCREEN_H / 2, RGBA8(160, 160, 165, 255), "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 * (Square toggles it); covers items up to its lower edge */ @@ -1804,6 +1934,127 @@ pf_skip: draw_hud("Up/Down scroll O back"); vita2d_end_drawing(); 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) { /* geotag map, opened from the map button on the search * bar. markers load on a worker thread the first time; diff --git a/src/display.c b/src/display.c index 7e50d3b..bc506c2 100644 --- a/src/display.c +++ b/src/display.c @@ -199,6 +199,26 @@ static void rebuild_display(void) 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 * (server assets only, date-grouped); local media isn't folded in */ if (g_search_active) { diff --git a/src/main.c b/src/main.c index 3e20c4b..30ebb76 100644 --- a/src/main.c +++ b/src/main.c @@ -158,6 +158,7 @@ static char (*g_tex_err)[160]; #include "display.c" /* merged server+local timeline + grid layout */ #include "ui.c" /* grid/detail/search/login/cloud screens */ #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 "sync.c" /* local scan/hash/check/upload + sync overview */ #include "app.c" /* main(): init, event loop, threads */ diff --git a/src/state.c b/src/state.c index 101ef24..5052068 100644 --- a/src/state.c +++ b/src/state.c @@ -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_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 * 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 @@ -249,6 +289,35 @@ static int grow_gallery(int need) 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, ...) { FILE *f = fopen(LOG_PATH, "a"); diff --git a/src/ui.c b/src/ui.c index 1696f53..d535de6 100644 --- a/src/ui.c +++ b/src/ui.c @@ -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); } +/* 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 */ static vita2d_texture *icon_tex(int which, int s) { - static vita2d_texture *cache[9]; - static int cs[9]; - if (which < 0 || which > 8) + static vita2d_texture *cache[10]; + static int cs[10]; + if (which < 0 || which > 9) return NULL; if (cache[which] && cs[which] == s) return cache[which]; @@ -291,7 +312,8 @@ static vita2d_texture *icon_tex(int which, int s) which == 4 ? cov_sq : which == 5 ? cov_cross : 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); cs[which] = s; return cache[which]; @@ -305,6 +327,7 @@ static vita2d_texture *icon_tex(int which, int s) #define ICON_CIRCLE 6 #define ICON_PLAY 7 #define ICON_CLOUD_LINE 8 +#define ICON_ALBUM 9 /* 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. */ @@ -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); } -/* pixel geometry of the search pill + the two buttons on its right, shared - * by the drawer and the tap test */ +/* pixel geometry of the search pill + the three buttons on its right + * (albums, map, cloud), shared by the drawer and the tap test */ #define BAR_M 12.0f /* left/right margin */ #define BAR_Y 8.0f /* top of the pill */ #define BAR_H 40.0f /* pill height */ #define BAR_R 12.0f /* corner radius */ #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 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 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 */ /* 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); - * `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) { 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, RGBA8(190, 190, 196, 255)); - /* focus rings behind the placeholder buttons */ - if (focus == 2 || focus == 3) { + /* focus rings behind the buttons */ + if (focus >= 2 && focus <= 4) { vita2d_texture *r = rounded_mask_tex((int)BTN_SZ + 6, (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) vita2d_draw_texture_tint(r, fcx - (BTN_SZ + 6) / 2, 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(BTN2_CX, cy, ICON_CLOUD); 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]; snprintf(q, sizeof(q), "%.40s", g_search_query); draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q);