Files
vitaImmich/src/app.c
T
SadsandClaude Opus 4.8 9f647c034a Places map HUD: back glyph, zoom indicator, Open button; AA discs
- Top-right "Back" + O glyph (drawn and tappable), matching the other pages.
- Bottom-left X "Open" affordance; bottom-right "L <zoom%> R" indicator with L
  and R pinned so the changing percentage can't shift them.
- Drop the old verbose hint line.
- Anti-alias the DualShock-style button discs: enlarge the size-keyed disc
  cache (8 -> 24) and pre-warm the button sizes at init, so cluster bubbles
  can no longer fill it before the buttons draw (which had fallen back to the
  aliased fill_circle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
2026-06-18 22:52:26 +02:00

2074 lines
66 KiB
C

/* 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.
*/
/* ------------------------------------------------------------------ */
/* main */
/* ------------------------------------------------------------------ */
enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS, MODE_MAP };
/* 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
* spinner from launch to grid instead of flashing a separate static screen per
* step. g_boot_status is the caption it shows; g_boot_done signals completion;
* g_boot_login_failed asks the main thread to drop to the interactive login. */
static char g_boot_status[48] = "Loading...";
static volatile int g_boot_done;
static volatile int g_boot_login_failed;
static int boot_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
/* stored email/password (no API key, not already signed in): try once */
if (!g_apikey[0] && g_email[0] && !g_token[0]) {
snprintf(g_boot_status, sizeof(g_boot_status), "Signing in...");
char lerr[160];
if (do_login(lerr, sizeof(lerr)) != 0) {
g_boot_login_failed = 1;
__sync_synchronize();
g_boot_done = 1;
return 0;
}
}
snprintf(g_boot_status, sizeof(g_boot_status), "Loading your library...");
fetch_page(0);
snprintf(g_boot_status, sizeof(g_boot_status), "Scanning local media...");
scan_local_media();
/* reserve headroom so a resume-from-sleep rescan can append new camera
* files without reallocating while the worker/sync threads read */
grow_locals(g_local_count + 512);
rebuild_display();
__sync_synchronize();
g_boot_done = 1;
return 0;
}
/* fetch the cloud page's server info off the main thread, so the page can
* animate a throbber while the request is in flight (fetch_server_info sets
* g_srv_state to 1/-1 when it finishes) */
static int srvinfo_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
fetch_server_info();
return 0;
}
/* fetch the map's geotagged markers off the main thread, so the map page
* can animate a throbber while the request is in flight (fetch_map_markers
* sets g_map_state to 1/-1 when it finishes) */
static int mapfetch_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
fetch_map_markers();
g_map_fetching = 0;
return 0;
}
/* after a logout + sign-in (possibly a different account): drop the old
* library/local lists and reload, with the sync thread paused and the
* thumbnail worker drained so nothing reads/writes the arrays mid-reset.
* main thread only. */
static void reset_for_account_change(void)
{
draw_loading("Switching account...", 0);
/* pause the sync thread (it reads g_local_* directly) */
g_pause_bg = 1;
for (int i = 0; i < 300 && !g_sync_idle; i++)
sceKernelDelayThread(10 * 1000);
/* drain any in-flight thumbnail request so a late REQ_DONE can't write
* g_thumb[] at an index that's about to become invalid */
for (int i = 0; i < 300 && g_req_state == REQ_PENDING; i++)
sceKernelDelayThread(10 * 1000);
if (g_req_state == REQ_DONE) {
free(g_req_pix); g_req_pix = NULL;
free(g_req_raw); g_req_raw = NULL;
}
g_req_state = REQ_IDLE;
g_req_idx = -1;
__sync_synchronize();
/* recycle every thumbnail and drop the old library + local lists */
vita2d_wait_rendering_done();
for (int i = 0; i < g_asset_count; i++)
if (g_thumb[i]) {
tex_release(g_thumb[i]);
g_thumb[i] = NULL;
g_thumb_failed[i] = 0;
}
for (int j = 0; j < g_local_count; j++)
if (g_local_thumb[j]) {
tex_release(g_local_thumb[j]);
g_local_thumb[j] = NULL;
}
g_asset_count = 0;
g_local_count = 0;
g_disp_count = 0;
g_sect_count = 0;
g_next_page = 1;
g_search_active = 0;
g_search_count = 0;
g_gallery_active = 0;
g_gallery_count = 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 */
g_map_fetching = 0;
g_map_view_init = 0;
__sync_synchronize();
/* reload from the new account (sync still paused), then resume */
fetch_page(0);
scan_local_media();
grow_locals(g_local_count + 512);
rebuild_display();
g_pause_bg = 0;
}
int main(void)
{
/* a larger vertex pool than the 1 MB default: the places map draws the
* whole world as filled vector triangles in one vita2d_draw_array call,
* which can be ~100k vertices when fully zoomed out */
vita2d_init_advanced(8 * 1024 * 1024);
vita2d_set_clear_color(RGBA8(16, 16, 16, 255));
/* pre-cache the face-button disc sizes so they're always anti-aliased,
* even on the map where cluster bubbles otherwise fill the disc cache
* before the buttons draw (see disc_tex) */
disc_tex(30); disc_tex(36); disc_tex(38);
g_font = vita2d_load_default_pgf();
g_ttf = vita2d_load_font_file("app0:font.ttf");
g_logo = vita2d_load_PNG_file("app0:logo.png");
g_ic_server = vita2d_load_PNG_file("app0:cloud_server.png");
g_ic_device = vita2d_load_PNG_file("app0:cloud_device.png");
g_ic_both = vita2d_load_PNG_file("app0:cloud_both.png");
load_maptiles(); /* raster LOD tile pyramid for the places map */
/* linear (bilinear) sampling so these scale/rotate smoothly instead of
* showing jagged stair-stepped edges (point sampling is the default) */
vita2d_texture *icons[] = { g_logo, g_ic_server, g_ic_device, g_ic_both };
for (unsigned i = 0; i < sizeof(icons) / sizeof(icons[0]); i++)
if (icons[i])
vita2d_texture_set_filters(icons[i],
SCE_GXM_TEXTURE_FILTER_LINEAR,
SCE_GXM_TEXTURE_FILTER_LINEAR);
/* on-screen keyboard (smart search). the IME runs as a common dialog;
* the config tells it the system language + enter/cancel button map */
sceSysmoduleLoadModule(SCE_SYSMODULE_IME);
{
SceCommonDialogConfigParam cfg;
sceCommonDialogConfigParamInit(&cfg);
sceCommonDialogSetConfigParam(&cfg);
}
/* analog mode so the left stick reports lx/ly for detail-view panning */
sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG);
/* front touch: drag scrolls the grid, a tap opens the item */
sceTouchSetSamplingState(SCE_TOUCH_PORT_FRONT,
SCE_TOUCH_SAMPLING_STATE_START);
/* start a fresh debug log each launch */
sceIoMkdir(CONFIG_DIR, 0777);
FILE *lf = fopen(LOG_PATH, "w");
if (lf)
fclose(lf);
/* Pre-grow the newlib heap while still single-threaded. vitasdk's
* newlib maps heap memblocks on demand as sbrk grows; on Vita3K the
* page-mapping done by one thread races other threads' JIT'd memory
* accesses, so a heap growth during a thumbnail download or page
* fetch intermittently faults on the freshly mapped pages (crash
* locations wander: OpenSSL GCM, libjpeg, strlen). Touching a large
* block now commits the arena up front, so the heap never grows once
* the worker/sync threads exist. dlmalloc keeps the sbrk'd arena
* after the free, which is also headroom AVPlayer's allocator needs
* for video playback anyway. */
{
/* never trim: a trimmed top chunk would shrink sbrk and unmap
* the pages again, reintroducing runtime heap growth */
mallopt(M_TRIM_THRESHOLD, 0x7fffffff);
size_t want = 96 * 1024 * 1024;
void *ball = NULL;
while (want >= 8 * 1024 * 1024 && !(ball = malloc(want)))
want /= 2;
if (ball) {
memset(ball, 0, want);
free(ball);
}
log_line("heap pre-grown: %u MB", (unsigned)(want >> 20));
}
show_status("Starting network...");
net_init();
/* runtime truth about what the linked libcurl supports */
{
curl_version_info_data *vi = curl_version_info(CURLVERSION_NOW);
log_line("libcurl %s | ssl: %s", vi->version,
vi->ssl_version ? vi->ssl_version : "(none)");
char plist[300] = "";
for (const char *const *pp = vi->protocols; *pp; pp++) {
strncat(plist, *pp, sizeof(plist) - strlen(plist) - 2);
strncat(plist, " ", sizeof(plist) - strlen(plist) - 1);
}
log_line("protocols: %s", plist);
}
int have_cfg = (load_config() == 0);
/* log the exact bytes of the server URL; invisible characters in the
* config show up here when curl complains about the protocol */
{
char hex[3 * 64 + 1] = "";
int n = strlen(g_server);
for (int i = 0; i < n && i < 64; i++)
sprintf(hex + 3 * i, "%02x ", (unsigned char)g_server[i]);
log_line("server='%s' len=%d hex=%s", g_server, n, hex);
}
/* auth: an API key needs nothing; missing config drops to interactive
* sign-in here. stored email/password is tried inside the boot thread. */
if (!have_cfg)
login_screen();
/* one continuous spinner: the boot thread signs in (if needed), fetches
* the first page and scans local media while the main thread animates the
* logo with a single, never-resetting frame counter. */
g_boot_done = 0;
g_boot_login_failed = 0;
SceUID bt = sceKernelCreateThread("boot", boot_thread,
0x10000100, 256 * 1024, 0, 0, NULL);
if (bt >= 0) {
sceKernelStartThread(bt, 0, NULL);
unsigned int bf = 0;
while (!g_boot_done) {
draw_loading(g_boot_status, bf++);
sceDisplayWaitVblankStart();
}
} else {
/* fallback: run the same steps blocking, without the spinner */
fetch_page(1);
scan_local_media();
grow_locals(g_local_count + 512);
rebuild_display();
}
/* a stored-credential sign-in failed: drop to interactive login (main
* thread), then load blocking */
if (g_boot_login_failed) {
login_screen();
fetch_page(1);
scan_local_media();
grow_locals(g_local_count + 512);
rebuild_display();
}
if (g_asset_count == 0)
fatal_error(NULL, "Server returned no assets (see log.txt)");
SceUID worker = sceKernelCreateThread("thumb_loader", worker_thread,
0x10000100, 256 * 1024, 0, 0, NULL);
if (worker >= 0)
sceKernelStartThread(worker, 0, NULL);
SceUID syncw = sceKernelCreateThread("sync_worker", sync_thread,
0x10000100, 256 * 1024, 0, 0, NULL);
if (syncw >= 0)
sceKernelStartThread(syncw, 0, NULL);
SceUID metaw = sceKernelCreateThread("meta_worker", meta_worker,
0x10000100, 128 * 1024, 0, 0, NULL);
if (metaw >= 0)
sceKernelStartThread(metaw, 0, NULL);
load_autobackup();
int mode = MODE_GRID;
int sel = 0;
int cloud_scroll = 0; /* first visible row on the upload-details list */
int show_sel = 1; /* selection square: shown for d-pad, hidden for touch */
int bar_shown = 1; /* top search bar visible */
float bar_hidden = 0.0f;/* animated pixels the bar is slid up (0..SEARCH_H) */
int bar_focus = 0; /* search bar focused (d-pad up from the top row) */
uint64_t last_tick = 0; /* wall-clock (incl. sleep) to detect resume */
/* periodic check for photos added to the server while we run */
uint64_t last_poll = sceKernelGetProcessTimeWide();
int grid_last_sel = -1; /* sel at the previous frame */
int grid_settle = 0; /* frames since sel last changed */
int rl_idle = 1000; /* frames since the last R/L month jump */
float scroll = 0.0f, target = 0.0f;
unsigned int prev_buttons = 0;
unsigned int held_frames = 0;
unsigned int frame = 0;
vita2d_texture *detail_tex = NULL;
int detail_idx = -1;
int detail_failed = 0;
int detail_from_map = 0; /* opened from the places map: O returns there */
int detail_loading = 0; /* full-res load in flight on the worker */
int detail_issued = 0; /* the worker request has been handed over */
float zoom = 1.0f, panx = 0.0f, pany = 0.0f;
/* front-touch state (grid: drag scrolls, tap opens; detail: swipe
* changes photo, drag pans when zoomed) */
int touch_active = 0; /* finger currently down */
int touch_dragged = 0; /* moved past the tap threshold */
int touch_on_bar = 0; /* contact began on the search bar */
float touch_x = 0, touch_y = 0; /* last position, screen px */
float touch_start_x = 0, touch_start_y = 0, touch_start_scroll = 0;
float touch_panx0 = 0, touch_pany0 = 0; /* pan at touch start (zoomed) */
float touch_vel = 0; /* px/frame at the moment of release */
float map_pinch = 0; /* finger spread last frame (map zoom) */
/* detail-view photo slide: the current photo's horizontal offset.
* a swipe maps it 1:1 to the finger; on release (or d-pad browse) it
* animates to +-SCREEN_W, the selection switches, and the neighbor's
* thumbnail rides alongside the whole way. */
float slide_x = 0, slide_goal = 0;
int slide_anim = 0;
/* detail-view neighbor prefetch: while the current photo is settled,
* the worker quietly fetches the full-res images (or video posters)
* of sel+1 / sel-1 so browsing lands sharp instantly */
vita2d_texture *pf_tex[2] = { NULL, NULL };
int pf_d[2] = { -1, -1 }; /* display slot each cache slot holds */
int pf_pending = -1; /* slot being fetched right now, -1 none */
for (;;) {
SceCtrlData pad;
sceCtrlPeekBufferPositive(0, &pad, 1);
unsigned int pressed = pad.buttons & ~prev_buttons;
prev_buttons = pad.buttons;
frame++;
/* resume from sleep: wall-clock (RTC) keeps running while the
* Vita is suspended, so a big jump between frames means we just
* woke — re-scan the camera folders for photos taken meanwhile */
{
SceRtcTick rt;
if (sceRtcGetCurrentTick(&rt) == 0) {
if (last_tick &&
rt.tick - last_tick > 3000000ULL) {
if (rescan_local_new() > 0)
rebuild_keep_view(&sel, &scroll,
&target);
}
last_tick = rt.tick;
}
}
/* auto-backup: while enabled, queue any newly-found local-only
* photos for upload (~1/s; the sync thread drains the queue).
* this covers both launch and photos found mid-session. */
if (g_autobackup && frame % 60 == 0 &&
count_state(SYNC_LOCAL_ONLY) > 0)
queue_all_uploads();
/* the sync thread asks us (the only writer of g_disp) to rebuild
* the merged timeline after a status change; keep the selection
* on the same photo across the reorder */
if (g_need_rebuild) {
g_need_rebuild = 0;
rebuild_keep_view(&sel, &scroll, &target);
detail_idx = -1; /* re-decode: item under sel may differ */
/* display slots were reshuffled: the prefetch cache's
* slot keys are meaningless now */
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;
}
}
/* d-pad auto-repeat for fast scrolling */
const unsigned int dirs = SCE_CTRL_UP | SCE_CTRL_DOWN |
SCE_CTRL_LEFT | SCE_CTRL_RIGHT;
held_frames = (pad.buttons & dirs) ? held_frames + 1 : 0;
unsigned int nav = pressed;
if (held_frames > 18 && held_frames % 5 == 0)
nav |= pad.buttons & dirs;
/* SELECT opens the cloud / backup page from the grid or detail */
if ((pressed & SCE_CTRL_SELECT) &&
(mode == MODE_GRID || mode == MODE_DETAIL)) {
mode = MODE_CLOUD;
continue;
}
if (mode == MODE_GRID) {
/* search bar: TRIANGLE opens the keyboard, CIRCLE (or
* the bar's clear chip) drops back to the timeline. a
* tap on the bar sets these too; handled after touch. */
int do_search_open = (pressed & SCE_CTRL_TRIANGLE) != 0;
int do_search_clear = g_search_active &&
(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 */
/* SQUARE slides the top bar in/out (when not focused);
* while focused it drops focus back to the grid */
if ((pressed & SCE_CTRL_SQUARE) && !bar_focus)
bar_shown = !bar_shown;
float bar_t = bar_shown ? 0.0f : (float)SEARCH_H;
bar_hidden += (bar_t - bar_hidden) * 0.3f;
if (fabsf(bar_t - bar_hidden) < 0.5f)
bar_hidden = bar_t;
/* the d-pad brings the selection square back — but not
* while the bar is focused; a touch drag hides it again */
if ((nav & dirs) && !bar_focus)
show_sel = 1;
if (bar_focus) {
/* bar focused (1=field, 2=map, 3=cloud): left/right
* move across the elements, down or [] returns to
* the grid, X activates the focused element */
show_sel = 0;
if (pressed & SCE_CTRL_SQUARE) {
/* drop to the grid AND hide the bar */
bar_focus = 0;
show_sel = 1;
bar_shown = 0;
} else if (nav & SCE_CTRL_DOWN) {
bar_focus = 0;
show_sel = 1;
} else {
if ((nav & SCE_CTRL_LEFT) && bar_focus > 1)
bar_focus--;
if ((nav & SCE_CTRL_RIGHT) && bar_focus < 3)
bar_focus++;
if (pressed & SCE_CTRL_CROSS) {
if (bar_focus == 1)
do_search_open = 1;
else if (bar_focus == 2)
do_open_map = 1;
else if (bar_focus == 3)
do_open_cloud = 1;
}
}
} else {
if (nav & SCE_CTRL_RIGHT)
sel++;
if (nav & SCE_CTRL_LEFT)
sel--;
if (sel < 0)
sel = 0;
if (sel >= g_disp_count)
sel = g_disp_count - 1;
if (nav & SCE_CTRL_DOWN)
sel = nav_row(sel, +1);
if (nav & SCE_CTRL_UP) {
int nr = nav_row(sel, -1);
if (nr == sel) { /* already on the top row */
bar_focus = 1;
bar_shown = 1; /* reveal the bar if hidden */
show_sel = 0;
} else {
sel = nr;
}
}
if ((pressed & (SCE_CTRL_RTRIGGER |
SCE_CTRL_LTRIGGER)) &&
g_disp_count > 0) {
int dir = (pressed & SCE_CTRL_RTRIGGER) ?
+1 : -1;
/* Only the FIRST deliberate jump after a pause
* may pull pages. rl_idle counts frames since the
* last R/L jump; a settle threshold alone isn't
* enough because mashing R at >12-frame intervals
* lets grid_settle creep back to 12 between
* presses, firing the blocking fetch+rebuild loop
* on every press. Gate on the PREVIOUS idle span
* (before resetting it for this press), so spam
* jumps stay purely in-memory and the catch-up
* fetch waits until you actually stop. */
int can_pull = (rl_idle >= 30);
rl_idle = 0;
/* jumping down: the next month may simply not
* be fetched yet — pull pages until a new month
* 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) {
int guard = 0;
while (g_next_page > 0 && guard++ < 10 &&
!strncmp(disp_date(month_jump(sel, +1)),
disp_date(sel), 7)) {
if (fetch_page(0) <= 0)
break;
rebuild_keep_view(&sel, &scroll,
&target);
}
}
sel = month_jump(sel, dir);
/* put the jumped-to month just below the pinned
* search bar, header band included */
if (sel >= 0 && sel < g_disp_count)
target = g_item_y[sel] - HEADER_H - SEARCH_H;
}
} /* end !bar_focus */
/* Track how long the selection has held still. A held
* d-pad auto-repeats one row every few frames; while it's
* moving we suspend the expensive/fragile work below:
* - thumbnail texture create/free (rapid GPU map+unmap
* churn fragments the address space until vita2d returns
* a partially-mapped texture and the draw faults), and
* - page fetching (otherwise a held key races sel to the
* end and fires fetch+rebuild every frame — a request
* storm that hammers the server and drives that churn).
* Everything resumes once you stop. Auto-repeat period is
* 5 frames, so a threshold of 12 covers it. */
if (sel != grid_last_sel) {
grid_last_sel = sel;
grid_settle = 0;
} else if (grid_settle < 1000) {
grid_settle++;
}
if (rl_idle < 1000)
rl_idle++;
int scrolling_fast = (grid_settle < 12);
if ((pressed & SCE_CTRL_CROSS) && g_disp_count > 0 &&
!bar_focus) {
/* photos and videos both open the detail
* view; a video shows its poster with the
* play button there */
mode = MODE_DETAIL;
detail_from_map = 0;
zoom = 1.0f;
panx = pany = 0.0f;
slide_x = slide_goal = 0;
slide_anim = 0;
}
/* fetch the next page when the selection settles near the
* end (gated on !scrolling_fast, see above). no loading
* 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 &&
sel >= g_disp_count - COLS * 4) {
if (fetch_page(0) > 0)
rebuild_keep_view(&sel, &scroll,
&target);
}
/* photos added to the server show up by themselves:
* poll the newest page every ~30 s while the grid is
* settled, keeping the selection on the same photo
* across the relayout */
uint64_t pnow = sceKernelGetProcessTimeWide();
if (!scrolling_fast && !touch_active && !g_search_active &&
!g_gallery_active &&
pnow - last_poll > 30ULL * 1000 * 1000) {
last_poll = pnow;
if (check_new_assets() > 0)
rebuild_keep_view(&sel, &scroll,
&target);
}
/* front touch: drag scrolls the timeline directly, a
* fling keeps it gliding, a tap opens the item under
* the finger. the front panel reports 2x screen px. */
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
float maxs = g_content_h - SCREEN_H;
if (maxs < 0)
maxs = 0;
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
if (!touch_active) {
touch_active = 1;
touch_dragged = 0;
touch_start_x = tx;
touch_start_y = ty;
touch_start_scroll = scroll;
touch_vel = 0;
bar_focus = 0; /* touching cancels bar focus */
/* a contact on the (visible) bar opens
* the keyboard instead of scrolling */
touch_on_bar =
(ty < (float)SEARCH_H - bar_hidden);
} else if (!touch_on_bar) {
float dy = ty - touch_start_y;
if (!touch_dragged &&
(dy > 14.0f || dy < -14.0f))
touch_dragged = 1;
if (touch_dragged) {
/* touch-scroll hides the
* selection square */
show_sel = 0;
float ns = touch_start_scroll - dy;
if (ns < 0)
ns = 0;
if (ns > maxs)
ns = maxs;
touch_vel = ns - scroll;
scroll = target = ns;
/* keep the selection inside
* the dragged viewport */
int c = item_near(ns +
(SEARCH_H + SCREEN_H) / 2);
if (c >= 0)
sel = c;
}
}
touch_x = tx;
touch_y = ty;
} else if (touch_active) {
touch_active = 0;
if (touch_on_bar) {
/* tap on the bar: the map/cloud
* buttons are placeholders (no-op),
* the clear chip clears an active
* search, the field opens the
* keyboard */
if (touch_x > BTN2_CX - BTN_SZ / 2) {
/* cloud button: open the
* server/backup page */
do_open_cloud = 1;
} else if (touch_x > BAR_PX + BAR_W) {
/* map button: open the map */
do_open_map = 1;
} else if (g_search_active &&
touch_x > BAR_CLEAR_CX - 16)
do_search_clear = 1;
else
do_search_open = 1;
touch_on_bar = 0;
} else if (!touch_dragged) {
/* tap: open the item under it
* (videos show their poster +
* play button in the detail) */
int i = item_at(touch_x,
touch_y + scroll + bar_hidden);
if (i >= 0) {
sel = i;
mode = MODE_DETAIL;
detail_from_map = 0;
zoom = 1.0f;
panx = pany = 0.0f;
slide_x = slide_goal = 0;
slide_anim = 0;
}
} else if (touch_vel > 2.0f ||
touch_vel < -2.0f) {
/* fling: glide on; aim the
* selection at the destination
* so scroll-follow doesn't
* fight the glide */
target = scroll + touch_vel * 18.0f;
if (target < 0)
target = 0;
if (target > maxs)
target = maxs;
int c = item_near(target +
(SEARCH_H + SCREEN_H) / 2);
if (c >= 0)
sel = c;
}
}
/* in a cluster gallery, O returns to the map; any
* bar action (search/cloud/map) drops the filter */
if (g_gallery_active && (pressed & SCE_CTRL_CIRCLE)) {
gallery_close();
mode = MODE_MAP;
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
touch_active = touch_on_bar = 0;
continue;
}
if (g_gallery_active &&
(do_open_cloud || do_open_map || do_search_open))
gallery_close();
if (do_open_cloud) {
mode = MODE_CLOUD;
continue;
}
if (do_open_map) {
mode = MODE_MAP;
touch_active = touch_on_bar = 0;
continue;
}
/* act on a search request from TRIANGLE/CIRCLE
* or a tap on the bar. both block this frame
* (keyboard / request), so restart the loop. */
if (do_search_clear) {
g_search_active = 0;
g_search_query[0] = '\0';
rebuild_display();
sel = 0;
scroll = target = 0;
detail_idx = -1;
grid_last_sel = -1;
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 (do_search_open) {
char q[128] = "";
int ok = ime_input("Search your photos",
g_search_active ? g_search_query : "",
q, sizeof(q), 0);
if (ok && q[0]) {
/* run the search on a worker thread and
* spin a throbber while it's in flight */
snprintf(g_search_pending,
sizeof(g_search_pending), "%s", q);
g_search_done = 0;
SceUID stid = sceKernelCreateThread("search",
search_thread, 0x10000100,
128 * 1024, 0, 0, NULL);
int nres;
if (stid >= 0) {
sceKernelStartThread(stid, 0, NULL);
char cap[160];
snprintf(cap, sizeof(cap),
"Searching \"%s\"...", q);
unsigned int sf = 0;
while (!g_search_done) {
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();
}
nres = g_search_result;
} else {
show_status("Searching \"%s\"...", q);
nres = run_smart_search(q);
}
if (nres >= 0) {
sel = 0;
scroll = target = 0;
detail_idx = -1;
grid_last_sel = -1;
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;
}
} else {
show_blocking_error(
"Search failed",
"Smart search may be "
"disabled on the server "
"(see log.txt).");
}
}
/* swallow buttons held during the dialog */
prev_buttons = 0xFFFFFFFF;
touch_active = touch_on_bar = 0;
continue;
}
}
/* scroll follows the selection, using the precomputed
* per-item y so month headers shift it correctly. when
* moving up, reveal the header band above the item. */
float sel_y = (sel >= 0 && sel < g_disp_count) ?
g_item_y[sel] : 0.0f;
/* reveal the header band below the pinned search bar */
if (sel_y - HEADER_H - SEARCH_H < target)
target = sel_y - HEADER_H - SEARCH_H;
float sel_h = (sel >= 0 && sel < g_disp_count) ?
g_item_h[sel] : ROW_H;
if (sel_y + sel_h > target + SCREEN_H)
target = sel_y + sel_h - SCREEN_H;
float max_scroll = g_content_h - SCREEN_H;
if (max_scroll < 0)
max_scroll = 0;
if (target < 0)
target = 0;
if (target > max_scroll)
target = max_scroll;
scroll += (target - scroll) * 0.35f;
/* on-screen scroll: the hidden bar shifts the grid up so
* it fills the vacated strip (the reveal-header math in
* scroll-follow cancels out, so it still uses `scroll`) */
float escroll = scroll + bar_hidden;
/* visible index range (item y is non-decreasing with i),
* computed in the same pass as thumb eviction below */
int first_vis = -1, last_vis = -1;
/* evict thumbs far outside the viewport (never the one
* the worker is currently loading); evicted textures
* are recycled through the pool, see tex_release */
for (int i = 0; i < g_disp_count; i++) {
float dy = g_item_y[i] - escroll;
if (dy > -g_item_h[i] && dy < SCREEN_H) {
if (first_vis < 0)
first_vis = i;
last_vis = i;
}
/* free thumbs well outside the viewport (only when
* not mid-scroll, see scrolling_fast above) */
if (!scrolling_fast && disp_thumb(i) &&
(dy < -1.25f * SCREEN_H || dy > 1.75f * SCREEN_H)) {
struct disp_item *it = &g_disp[i];
/* don't free a thumb the worker is filling */
if (g_req_state != REQ_IDLE &&
g_req_idx == it->idx &&
g_req_src == it->src)
continue;
/* recycle, never free: the GPU side may
* still touch a freed texture's memory */
tex_release(disp_thumb(i));
if (it->src == SRC_LOCAL)
g_local_thumb[it->idx] = NULL;
else
g_thumb[it->idx] = NULL;
}
}
if (first_vis < 0) { /* nothing matched (empty grid) */
first_vis = 0;
last_vis = g_disp_count - 1;
}
/* cluster gallery: resolve members' date/aspect in the
* background so the grid sorts into months + true aspects */
if (g_gallery_active)
gallery_meta_pump(first_vis, last_vis,
&sel, &scroll, &target);
/* collect finished download, then hand the worker
* the next most useful thumbnail */
if (g_req_state == REQ_DONE) {
__sync_synchronize();
consume_worker_result();
}
if (g_req_state == REQ_IDLE && !scrolling_fast) {
int next = pick_next_load(sel, first_vis, last_vis);
if (next >= 0) {
struct disp_item *it = &g_disp[next];
g_req_idx = it->idx;
g_req_src = it->src;
g_req_detail = 0;
/* copy the payload so the worker needn't
* index arrays we may realloc */
if (it->src == SRC_LOCAL) {
snprintf(g_req_path, sizeof(g_req_path),
"%s", g_local_path[it->idx]);
g_req_is_video =
g_local_is_video[it->idx];
} else {
snprintf(g_req_id, sizeof(g_req_id),
"%s", g_asset_ids[it->idx]);
g_req_is_video = 0;
}
__sync_synchronize();
g_req_state = REQ_PENDING;
}
}
vita2d_start_drawing();
vita2d_clear_screen();
/* month/year header bands */
for (int h = 0; h < g_sect_count; h++) {
float hy = g_sect[h].y - escroll;
if (hy + HEADER_H < 0 || hy > SCREEN_H)
continue;
draw_text(10, hy + HEADER_H - 12,
RGBA8(255, 255, 255, 255),
1.1f, g_sect[h].label);
vita2d_draw_rectangle(10, hy + HEADER_H - 6,
SCREEN_W - 20, 2,
RGBA8(90, 90, 90, 255));
}
for (int i = first_vis; i >= 0 && i <= last_vis; i++) {
float x = g_item_x[i];
float y = g_item_y[i] - escroll;
float bx = x + CELL_PAD, by = y + CELL_PAD;
float bw = g_item_w[i] - 2 * CELL_PAD;
float bh = g_item_h[i] - 2 * CELL_PAD;
vita2d_texture *th = disp_thumb(i);
if (th)
draw_texture_fitted(th, bx, by, bw, bh);
else if (disp_thumb_failed(i))
vita2d_draw_rectangle(bx, by, bw, bh,
RGBA8(90, 30, 30, 255));
else
vita2d_draw_rectangle(bx, by, bw, bh,
RGBA8(40, 40, 40, 255));
if (disp_is_video(i)) {
vita2d_draw_rectangle(bx, by + bh - 26,
64, 26,
RGBA8(0, 0, 0, 170));
draw_text(bx + 6,
by + bh - 7,
RGBA8(255, 255, 255, 255),
0.85f, "VIDEO");
}
draw_status_badge(bx, by, bw, bh, i, frame);
if (i == sel && show_sel)
draw_sel_outline(x + 2, y + 2,
g_item_w[i] - 4,
g_item_h[i] - 4);
}
if (g_search_active && g_disp_count == 0)
draw_centered(SCREEN_H / 2,
RGBA8(160, 160, 165, 255),
"No results");
/* pinned Immich-style search bar, slid up by bar_hidden
* (Square toggles it); covers items up to its lower edge */
draw_search_bar(-bar_hidden, bar_focus);
char hud[200];
if (g_search_active)
snprintf(hud, sizeof(hud),
"%d / %d results /\\ edit O clear "
"X view [] bar",
g_disp_count > 0 ? sel + 1 : 0,
g_disp_count);
else
snprintf(hud, sizeof(hud),
"%d / %d%s %.10s X view /\\ search "
"[] bar",
sel + 1, g_disp_count,
g_next_page > 0 ? "+" : "",
g_disp_count > 0 ? disp_date(sel) : "");
draw_hud(hud);
vita2d_end_drawing();
vita2d_swap_buffers();
} else if (mode == MODE_DETAIL) {
struct disp_item it = g_disp[sel];
int is_local = (it.src == SRC_LOCAL);
int is_video = disp_is_video(sel);
/* zoom with the triggers or the right stick (push up to
* zoom in, down to zoom out), continuous while held */
if (!is_video && detail_tex) {
if (pad.buttons & SCE_CTRL_RTRIGGER)
zoom *= 1.04f;
if (pad.buttons & SCE_CTRL_LTRIGGER)
zoom /= 1.04f;
float rz = (128 - pad.ry) / 128.0f; /* up positive */
if (rz > 0.18f || rz < -0.18f)
zoom *= 1.0f + rz * 0.05f;
if (zoom > 8.0f)
zoom = 8.0f;
if (zoom < 1.0f) {
zoom = 1.0f;
panx = pany = 0.0f;
}
}
int zoomed = (zoom > 1.001f);
if (zoomed) {
/* pan with the left stick + d-pad; don't browse */
float dx = (pad.lx - 128) / 128.0f;
float dy = (pad.ly - 128) / 128.0f;
if (dx > 0.15f || dx < -0.15f)
panx -= dx * 16.0f;
if (dy > 0.15f || dy < -0.15f)
pany -= dy * 16.0f;
if (pad.buttons & SCE_CTRL_RIGHT) panx -= 12.0f;
if (pad.buttons & SCE_CTRL_LEFT) panx += 12.0f;
if (pad.buttons & SCE_CTRL_UP) pany += 12.0f;
if (pad.buttons & SCE_CTRL_DOWN) pany -= 12.0f;
} else if (!slide_anim && !touch_active) {
/* browse with a slide animation; the selection
* switches when the slide completes */
if (nav & SCE_CTRL_RIGHT && sel < g_disp_count - 1) {
slide_anim = 1;
slide_goal = -SCREEN_W;
}
if (nav & SCE_CTRL_LEFT && sel > 0) {
slide_anim = 1;
slide_goal = SCREEN_W;
}
}
/* front touch: when zoomed a drag pans the photo 1:1;
* otherwise a horizontal swipe drags the photo (and its
* neighbor) 1:1 and a release past the threshold flips
* to it, anything less springs back */
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
if (!touch_active) {
touch_active = 1;
touch_dragged = 0;
touch_start_x = tx;
touch_start_y = ty;
touch_panx0 = panx;
touch_pany0 = pany;
} else {
float dx = tx - touch_start_x;
float dy = ty - touch_start_y;
if (!touch_dragged &&
(dx > 12.0f || dx < -12.0f ||
dy > 12.0f || dy < -12.0f))
touch_dragged = 1;
if (touch_dragged && zoomed) {
panx = touch_panx0 + dx;
pany = touch_pany0 + dy;
} else if (touch_dragged &&
!slide_anim) {
slide_x = dx;
/* nothing beyond the ends */
if (sel <= 0 && slide_x > 0)
slide_x = 0;
if (sel >= g_disp_count - 1 &&
slide_x < 0)
slide_x = 0;
}
}
touch_x = tx;
touch_y = ty;
} else if (touch_active) {
touch_active = 0;
if (touch_dragged && !zoomed && !slide_anim) {
if (slide_x < -SCREEN_W / 4.0f &&
sel < g_disp_count - 1)
slide_goal = -SCREEN_W;
else if (slide_x > SCREEN_W / 4.0f &&
sel > 0)
slide_goal = SCREEN_W;
else
slide_goal = 0; /* spring back */
slide_anim = 1;
} else if (!touch_dragged && is_video &&
!slide_anim) {
/* tap on the poster plays it */
g_dl_bg = detail_tex ?
detail_tex :
disp_thumb(sel);
g_video_rot = 0;
if (is_local)
play_video_file(g_local_path[it.idx]);
else
view_video(it.idx);
g_dl_bg = NULL;
do {
sceCtrlPeekBufferPositive(0, &pad, 1);
sceKernelDelayThread(10 * 1000);
} while (pad.buttons);
prev_buttons = 0;
continue;
}
}
}
/* run the slide animation; at the end the selection
* moves and the (centered) neighbor becomes current */
if (slide_anim && !touch_active) {
slide_x += (slide_goal - slide_x) * 0.35f;
float dd = slide_goal - slide_x;
if (dd < 4.0f && dd > -4.0f) {
if (slide_goal < -1.0f && sel < g_disp_count - 1)
sel++;
else if (slide_goal > 1.0f && sel > 0)
sel--;
slide_x = 0;
slide_goal = 0;
slide_anim = 0;
}
}
if (pressed & SCE_CTRL_CIRCLE) {
/* back to wherever we came from (the map if the
* photo was opened from a marker, else the grid —
* which may be showing a cluster gallery) */
mode = detail_from_map ? MODE_MAP : MODE_GRID;
detail_from_map = 0;
detail_idx = -1;
detail_loading = 0;
slide_x = slide_goal = 0;
slide_anim = 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 (pressed & SCE_CTRL_CROSS) {
if (is_video) {
/* play even if the poster fetch failed */
g_dl_bg = detail_tex ?
detail_tex : disp_thumb(sel);
g_video_rot = 0;
if (is_local)
play_video_file(g_local_path[it.idx]);
else
view_video(it.idx);
g_dl_bg = NULL;
/* wait for the buttons used inside the
* player to be released, so they don't
* also act on this screen */
do {
sceCtrlPeekBufferPositive(0, &pad, 1);
sceKernelDelayThread(10 * 1000);
} while (pad.buttons);
prev_buttons = 0;
continue;
}
if (detail_failed)
detail_idx = -1; /* retry */
}
/* left/right may have moved the selection; refresh the
* item we load + draw below (the X handler above acted
* on the item shown when the frame started) */
it = g_disp[sel];
is_local = (it.src == SRC_LOCAL);
is_video = disp_is_video(sel);
/* collect a finished neighbor prefetch (and drop it
* if browsing already moved past its neighborhood) */
if (pf_pending >= 0 && g_req_state == REQ_DONE) {
__sync_synchronize();
vita2d_texture *t = consume_detail_result();
int keep = (pf_pending == sel - 1 ||
pf_pending == sel + 1 ||
pf_pending == sel);
if (t && keep) {
int slot = pf_tex[0] ? 1 : 0;
if (pf_tex[slot]) {
vita2d_wait_rendering_done();
vita2d_free_texture(pf_tex[slot]);
}
pf_tex[slot] = t;
pf_d[slot] = pf_pending;
} else if (t) {
vita2d_wait_rendering_done();
vita2d_free_texture(t);
}
pf_pending = -1;
}
if (detail_idx != sel) {
zoom = 1.0f; /* reset view for the new photo */
panx = pany = 0.0f;
if (detail_tex) {
/* the GPU may still be drawing the
* previous frame with this texture */
vita2d_wait_rendering_done();
vita2d_free_texture(detail_tex);
detail_tex = NULL;
}
detail_failed = 0;
/* photos (and server videos, whose poster is
* the same preview endpoint) load async on the
* worker; the blown-up grid thumb shows
* meanwhile and O stays responsive */
detail_loading = (!is_video ||
it.src == SRC_SERVER);
detail_issued = 0;
detail_idx = sel;
/* prefetched? take it and skip the load */
for (int k = 0; k < 2; k++) {
if (pf_d[k] == sel && pf_tex[k]) {
detail_tex = pf_tex[k];
pf_tex[k] = NULL;
pf_d[k] = -1;
detail_loading = 0;
}
}
/* drop cache entries no longer adjacent */
for (int k = 0; k < 2; k++) {
if (pf_d[k] >= 0 &&
(pf_d[k] < sel - 1 ||
pf_d[k] > sel + 1)) {
vita2d_wait_rendering_done();
vita2d_free_texture(pf_tex[k]);
pf_tex[k] = NULL;
pf_d[k] = -1;
}
}
}
/* drive the async full-res load */
if (detail_loading) {
if (!detail_issued) {
if (pf_pending < 0 &&
g_req_state == REQ_DONE) {
__sync_synchronize();
consume_worker_result();
}
if (g_req_state == REQ_IDLE &&
disp_wants_thumb(sel)) {
/* no low-res preview at all yet
* (opened mid-scroll): fetch the
* quick thumb first so something
* shows; the full-res request
* follows right after */
req_issue_thumb(sel);
} else if (g_req_state == REQ_IDLE) {
g_req_idx = it.idx;
g_req_src = it.src;
g_req_detail = 1;
g_req_is_video = 0;
if (is_local)
snprintf(g_req_path,
sizeof(g_req_path), "%s",
g_local_path[it.idx]);
else
snprintf(g_req_id,
sizeof(g_req_id), "%s",
g_asset_ids[it.idx]);
__sync_synchronize();
g_req_state = REQ_PENDING;
detail_issued = 1;
}
} else if (g_req_state == REQ_DONE) {
__sync_synchronize();
detail_tex = consume_detail_result();
detail_loading = 0;
detail_failed = (detail_tex == NULL);
}
} else if (pf_pending < 0) {
/* a stale detail result (the user moved on
* while it was in flight) would wedge the
* request slot: discard it */
if (g_req_state == REQ_DONE) {
__sync_synchronize();
consume_worker_result();
}
if (g_req_state != REQ_IDLE ||
slide_anim || touch_active)
goto pf_skip;
/* the current item's own thumb first (e.g. a
* local video opened before its poster was
* extracted), then the neighbors': their grid
* thumbs are what the slide shows and what
* fills the screen the instant a swipe lands.
* the full-res prefetch follows. */
if (disp_wants_thumb(sel)) {
req_issue_thumb(sel);
goto pf_skip; /* request slot taken */
}
int want[2] = { sel + 1, sel - 1 };
for (int k = 0; k < 2; k++) {
int t = want[k];
if (t < 0 || t >= g_disp_count)
continue;
if (!disp_wants_thumb(t))
continue;
req_issue_thumb(t);
goto pf_skip; /* request slot taken */
}
for (int k = 0; k < 2; k++) {
int t = want[k];
if (t < 0 || t >= g_disp_count)
continue;
if (pf_d[0] == t || pf_d[1] == t)
continue;
struct disp_item *ti = &g_disp[t];
if (ti->src == SRC_LOCAL &&
g_local_is_video[ti->idx])
continue; /* no poster re-extract */
g_req_idx = ti->idx;
g_req_src = ti->src;
g_req_detail = 1;
g_req_is_video = 0;
if (ti->src == SRC_LOCAL)
snprintf(g_req_path,
sizeof(g_req_path), "%s",
g_local_path[ti->idx]);
else
snprintf(g_req_id,
sizeof(g_req_id), "%s",
g_asset_ids[ti->idx]);
__sync_synchronize();
g_req_state = REQ_PENDING;
pf_pending = t;
break;
}
}
pf_skip:
vita2d_start_drawing();
vita2d_clear_screen();
int sliding = (slide_anim ||
slide_x > 0.5f || slide_x < -0.5f);
if (sliding) {
/* current photo offset by the slide, neighbor
* riding alongside (its grid thumb blown up;
* the full image loads once it lands) */
if (detail_tex)
draw_texture_fitted(detail_tex, slide_x,
0, SCREEN_W, SCREEN_H);
else {
vita2d_texture *th = disp_thumb(sel);
if (th)
draw_texture_fitted(th, slide_x, 0,
SCREEN_W,
SCREEN_H);
else
draw_photo_placeholder(slide_x,
sel, frame);
}
int going_next = (slide_x < 0 || slide_goal < 0);
int nb = going_next ? sel + 1 : sel - 1;
float nx = slide_x +
(going_next ? SCREEN_W : -SCREEN_W);
if (nb >= 0 && nb < g_disp_count) {
/* prefer the prefetched full-res */
vita2d_texture *th = NULL;
if (pf_d[0] == nb)
th = pf_tex[0];
else if (pf_d[1] == nb)
th = pf_tex[1];
if (!th)
th = disp_thumb(nb);
if (th)
draw_texture_fitted(th, nx, 0,
SCREEN_W,
SCREEN_H);
else
draw_photo_placeholder(nx, nb, frame);
}
} else if (is_video) {
/* video: its poster with a play button (the
* poster is the detail load for server videos,
* the grid thumb for local ones) */
vita2d_texture *poster = detail_tex ?
detail_tex : disp_thumb(sel);
if (poster)
draw_texture_fitted(poster, 0, 0,
SCREEN_W, SCREEN_H);
draw_play_overlay();
} else if (detail_tex) {
draw_texture_zoom(detail_tex, zoom, &panx, &pany);
} else if (detail_loading) {
/* low-res grid thumb blown up while the full
* image is still on its way; no preview yet ->
* a grey placeholder sized to the photo */
vita2d_texture *th = disp_thumb(sel);
if (th)
draw_texture_fitted(th, 0, 0,
SCREEN_W, SCREEN_H);
else
draw_photo_placeholder(0, sel, frame);
} else if (it.src == SRC_SERVER) {
draw_error_detail(it.idx);
} else {
draw_centered(SCREEN_H / 2,
RGBA8(255, 80, 80, 255),
"Failed to load this photo");
}
const char *xhint = is_video ? "X play " :
detail_failed ? "X retry " : "";
char hud[200];
if (zoom > 1.001f)
snprintf(hud, sizeof(hud),
"%d / %d %.0f%% stick/d-pad pan L/R zoom O back",
sel + 1, g_disp_count, zoom * 100.0f);
else
snprintf(hud, sizeof(hud),
"%d / %d %.19s < > browse L/R zoom %sO back",
sel + 1, g_disp_count,
g_disp_count > 0 ? disp_date(sel) : "",
xhint);
draw_hud(hud);
/* throbber centred over the preview while the full-res
* image is still loading */
if (detail_loading && disp_thumb(sel) && !zoomed && !sliding)
draw_throbber(SCREEN_W / 2.0f, SCREEN_H / 2.0f,
24.0f, frame);
vita2d_end_drawing();
vita2d_swap_buffers();
} else if (mode == MODE_CLOUD) {
/* server + backup overview, opened from the cloud
* button on the search bar */
if (g_srv_state == 0) {
/* fetch on a worker thread so the page animates a
* throbber instead of freezing on the request */
if (!g_srv_fetching) {
g_srv_fetching = 1;
SceUID st = sceKernelCreateThread("srvinfo",
srvinfo_thread, 0x10000100,
64 * 1024, 0, 0, NULL);
if (st >= 0)
sceKernelStartThread(st, 0, NULL);
else
fetch_server_info(); /* fallback */
}
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 server info...");
if (pressed & SCE_CTRL_CIRCLE)
mode = MODE_GRID;
vita2d_end_drawing();
vita2d_swap_buffers();
continue;
}
int total = g_local_count;
int backed = count_state(SYNC_BACKED_UP);
int remain = total - backed;
/* auto-upload toggle switch + on-screen action buttons
* (also [] = toggle, X = details, /\ = upload) */
float tgw = 60, tgh = 30, tgx = 470, tgy = 350;
float bth = 46, bty = 408;
float dbx = 56, dbw = 300; /* See details (X glyph left) */
float ubx = 402, ubw = 300; /* Upload all (/\ glyph left) */
int go_details = (pressed & SCE_CTRL_CROSS) != 0;
int do_upload = (pressed & SCE_CTRL_TRIANGLE) != 0;
int do_toggle = (pressed & SCE_CTRL_SQUARE) != 0;
int do_back = (pressed & SCE_CTRL_CIRCLE) != 0;
int do_logout = 0;
/* O / Back button in the top-right corner */
float bkd = 36, bkcx = SCREEN_W - 30, bkcy = 36;
/* Log out button in the top-left corner */
float lox = 16, loy = 18, low = 120, loh = 36;
/* touch: tap the button to open the details list */
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
if (!touch_active) {
touch_active = 1;
touch_dragged = 0;
touch_start_x = tx;
touch_start_y = ty;
} else if (fabsf(tx - touch_start_x) > 12 ||
fabsf(ty - touch_start_y) > 12) {
touch_dragged = 1;
}
touch_x = tx;
touch_y = ty;
} else if (touch_active) {
touch_active = 0;
if (touch_dragged) {
/* ignore drags */
} else if (touch_y < bkcy + bkd &&
touch_x > bkcx - bkd - 70) {
do_back = 1; /* top-right Back */
} else if (touch_x >= lox &&
touch_x <= lox + low &&
touch_y >= loy &&
touch_y <= loy + loh) {
do_logout = 1; /* top-left Log out */
} else if (touch_y >= tgy - 6 &&
touch_y <= tgy + tgh + 6 &&
touch_x >= 60 && touch_x <= tgx + tgw) {
do_toggle = 1;
} else if (touch_y >= bty &&
touch_y <= bty + bth) {
if (touch_x >= dbx &&
touch_x <= dbx + dbw)
go_details = 1;
else if (touch_x >= ubx &&
touch_x <= ubx + ubw)
do_upload = 1;
}
}
}
if (do_back) {
mode = MODE_GRID;
continue;
}
if (do_logout) {
/* best-effort server logout, clear credentials,
* persist, then return to the sign-in screen */
membuf lb;
long lc;
char lu[600];
snprintf(lu, sizeof(lu), "%s/api/auth/logout",
g_server);
http_request(lu, "{}", &lb, &lc, NULL, 0);
free(lb.data);
g_token[0] = g_apikey[0] = '\0';
g_email[0] = g_password[0] = '\0';
g_serverip[0] = '\0'; /* drop the old LAN-IP pin */
save_config();
login_screen(); /* blocks until re-signed-in */
/* drop the old account's library + reload */
reset_for_account_change();
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
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;
detail_idx = -1;
prev_buttons = 0xFFFFFFFF; /* swallow held keys */
touch_active = 0;
continue;
}
if (do_toggle) {
g_autobackup = !g_autobackup;
save_autobackup();
if (g_autobackup)
queue_all_uploads();
log_line("cloud: auto-upload %s",
g_autobackup ? "on" : "off");
}
if (do_upload) {
int q = queue_all_uploads();
log_line("cloud: queued %d uploads", q);
}
if (go_details) {
mode = MODE_CLOUD_DETAILS;
cloud_scroll = 0;
continue;
}
vita2d_start_drawing();
vita2d_clear_screen();
draw_centered(40, RGBA8(255, 255, 255, 255),
"Server & Backup");
/* top-right O / Back button: "Back" label then the circle */
{
int bw = text_width(0.95f, "Back");
draw_text(bkcx - bkd / 2 - 8 - bw, bkcy + 6,
RGBA8(220, 220, 225, 255), 0.95f, "Back");
draw_ps_button(bkcx, bkcy, bkd, ICON_CIRCLE,
RGBA8(235, 90, 85, 255), 26);
}
/* top-left Log out button */
{
vita2d_texture *lo = rounded_mask_tex((int)low,
(int)loh, 10.0f);
if (lo)
vita2d_draw_texture_tint(lo, lox, loy,
RGBA8(60, 46, 52, 255));
int w = text_width(0.9f, "Log out");
draw_text(lox + (low - w) / 2, loy + loh - 13,
RGBA8(235, 200, 205, 255), 0.9f, "Log out");
}
char line[300];
uint32_t c = RGBA8(220, 220, 220, 255);
uint32_t dim = RGBA8(150, 150, 155, 255);
int y = 92;
/* server storage with a usage bar */
if (g_srv_state == 1 && g_srv_total[0]) {
snprintf(line, sizeof(line),
"Server storage: %s of %s used (%d%%)",
g_srv_use[0] ? g_srv_use : "?",
g_srv_total, g_srv_pct);
draw_text(60, y, c, 1.0f, line);
y += 24;
float bw = 600, bh = 14;
vita2d_draw_rectangle(60, y, bw, bh,
RGBA8(48, 48, 54, 255));
float f = g_srv_pct / 100.0f;
if (f < 0) f = 0;
if (f > 1) f = 1;
vita2d_draw_rectangle(60, y, bw * f, bh,
RGBA8(94, 110, 215, 255));
y += 40;
} else {
draw_text(60, y, dim, 1.0f,
"Server storage: unavailable");
y += 40;
}
snprintf(line, sizeof(line), "Server version: %s",
g_srv_version[0] ? g_srv_version : "unknown");
draw_text(60, y, c, 1.0f, line); y += 30;
snprintf(line, sizeof(line), "Server URL: %.48s", g_server);
draw_text(60, y, c, 1.0f, line); y += 30;
snprintf(line, sizeof(line), "App version: %s", APP_VERSION);
draw_text(60, y, c, 1.0f, line); y += 44;
draw_text(60, y, RGBA8(255, 255, 255, 255), 1.0f,
"On this PS Vita"); y += 30;
snprintf(line, sizeof(line), " Images detected: %d", total);
draw_text(60, y, c, 1.0f, line); y += 28;
snprintf(line, sizeof(line), " Backed up: %d", backed);
draw_text(60, y, RGBA8(120, 200, 120, 255), 1.0f, line);
y += 28;
snprintf(line, sizeof(line), " Remaining: %d", remain);
draw_text(60, y, RGBA8(220, 180, 120, 255), 1.0f, line);
/* auto-upload toggle switch */
draw_text(60, tgy + 21, c, 1.0f, "Auto-upload new photos");
vita2d_texture *tg = rounded_mask_tex((int)tgw, (int)tgh,
tgh / 2.0f);
uint32_t trk = g_autobackup ? RGBA8(90, 180, 110, 255)
: RGBA8(70, 70, 76, 255);
if (tg)
vita2d_draw_texture_tint(tg, tgx, tgy, trk);
else
vita2d_draw_rectangle(tgx, tgy, tgw, tgh, trk);
float kx = g_autobackup ? tgx + tgw - tgh / 2.0f
: tgx + tgh / 2.0f;
draw_disc(kx, tgy + tgh / 2.0f, tgh - 6.0f,
RGBA8(245, 245, 250, 255));
draw_text(tgx + tgw + 14, tgy + 21,
g_autobackup ? RGBA8(120, 200, 120, 255) : dim,
0.95f, g_autobackup ? "ON" : "OFF");
/* PS square button cueing the [] shortcut, left of switch */
draw_ps_button(tgx - 23, tgy + tgh / 2, 38, ICON_SQUARE,
RGBA8(232, 120, 175, 255), 31);
/* See details + Upload all buttons */
vita2d_texture *db = rounded_mask_tex((int)dbw, (int)bth, 10.0f);
if (db)
vita2d_draw_texture_tint(db, dbx, bty,
RGBA8(46, 46, 52, 255));
else
vita2d_draw_rectangle(dbx, bty, dbw, bth,
RGBA8(46, 46, 52, 255));
snprintf(line, sizeof(line), "See details (%d remaining)", remain);
draw_text(dbx + 18, bty + bth - 16,
RGBA8(230, 230, 235, 255), 0.95f, line);
/* PS cross button cueing the X shortcut, left of details */
draw_ps_button(dbx - 23, bty + bth / 2, 38, ICON_CROSS,
RGBA8(120, 150, 235, 255), 37);
vita2d_texture *ub = rounded_mask_tex((int)ubw, (int)bth, 10.0f);
/* the upload button reads as active (indigo) when there's
* something to send, dimmed when everything is backed up */
uint32_t uc = remain > 0 ? RGBA8(94, 110, 215, 255)
: RGBA8(46, 46, 52, 255);
if (ub)
vita2d_draw_texture_tint(ub, ubx, bty, uc);
else
vita2d_draw_rectangle(ubx, bty, ubw, bth, uc);
draw_text(ubx + 18, bty + bth - 16,
RGBA8(245, 245, 250, 255), 0.95f, "Upload all");
/* PS triangle button cueing the /\ shortcut, left of upload */
draw_ps_button(ubx - 23, bty + bth / 2, 38, ICON_TRI,
RGBA8(95, 205, 130, 255), 28);
draw_centered(SCREEN_H - 56, RGBA8(150, 150, 158, 255),
"Made by SadsArches with love");
draw_hud("X / tap details O back");
vita2d_end_drawing();
vita2d_swap_buffers();
} else if (mode == MODE_CLOUD_DETAILS) {
/* scrollable list of not-backed-up local files + status */
float bkd = 36, bkcx = SCREEN_W - 30, bkcy = 36;
int do_back = (pressed & SCE_CTRL_CIRCLE) != 0;
/* touch: tap the top-right Back button */
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
if (!touch_active) {
touch_active = 1;
if (ty < bkcy + bkd && tx > bkcx - bkd)
do_back = 1;
}
} else {
touch_active = 0;
}
}
if (do_back) {
mode = MODE_CLOUD;
continue;
}
const int top = 86, rowh = 28, rows = (SCREEN_H - top - 40) / rowh;
int total_unbacked = g_local_count - count_state(SYNC_BACKED_UP);
int maxscroll = total_unbacked - rows;
if (maxscroll < 0)
maxscroll = 0;
if (nav & SCE_CTRL_DOWN)
cloud_scroll++;
if (nav & SCE_CTRL_UP)
cloud_scroll--;
if (cloud_scroll > maxscroll)
cloud_scroll = maxscroll;
if (cloud_scroll < 0)
cloud_scroll = 0;
vita2d_start_drawing();
vita2d_clear_screen();
char line[300];
snprintf(line, sizeof(line), "Not backed up (%d)",
total_unbacked);
draw_centered(40, RGBA8(255, 255, 255, 255), line);
/* walk local files, skipping backed-up ones, render the
* window [cloud_scroll, cloud_scroll+rows) */
int seen = 0, drawn = 0, y = top;
for (int j = 0; j < g_local_count && drawn < rows; j++) {
if (g_local_state[j] == SYNC_BACKED_UP)
continue;
if (seen++ < cloud_scroll)
continue;
const char *p = g_local_path[j];
const char *slash = strrchr(p, '/');
const char *name = slash ? slash + 1 : p;
int st = g_local_state[j];
const char *stx = st == SYNC_HASHING ? "hashing" :
st == SYNC_UPLOADING ? "uploading" :
st == SYNC_FAILED ? "failed" :
"waiting";
uint32_t sc = st == SYNC_UPLOADING ?
RGBA8(120, 200, 120, 255) :
st == SYNC_HASHING ?
RGBA8(220, 210, 120, 255) :
st == SYNC_FAILED ?
RGBA8(220, 120, 120, 255) :
RGBA8(170, 170, 178, 255);
snprintf(line, sizeof(line), "%.46s", name);
draw_text(40, y, RGBA8(220, 220, 220, 255),
0.9f, line);
draw_text(SCREEN_W - 220, y, sc, 0.9f, stx);
y += rowh;
drawn++;
}
if (total_unbacked == 0)
draw_centered(SCREEN_H / 2,
RGBA8(120, 200, 120, 255),
"Everything is backed up");
/* scroll position: "first-last of total" + a scrollbar */
if (total_unbacked > rows) {
snprintf(line, sizeof(line), "%d-%d of %d",
cloud_scroll + 1, cloud_scroll + drawn,
total_unbacked);
draw_centered(66, RGBA8(150, 150, 158, 255), line);
float trx = SCREEN_W - 14, ttop = top - 2;
float tht = (float)rows * rowh;
vita2d_draw_rectangle(trx, ttop, 4, tht,
RGBA8(60, 60, 66, 255));
float th_h = tht * (float)rows / total_unbacked;
if (th_h < 18)
th_h = 18;
float th_y = ttop + (tht - th_h) *
(float)cloud_scroll / maxscroll;
vita2d_draw_rectangle(trx, th_y, 4, th_h,
RGBA8(160, 160, 170, 255));
}
/* top-right Back button (also press O) */
draw_ps_button(bkcx, bkcy, bkd, ICON_CIRCLE,
RGBA8(235, 90, 85, 255), 26);
draw_hud("Up/Down scroll O back");
vita2d_end_drawing();
vita2d_swap_buffers();
} 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;
* the page animates a throbber until they arrive. */
if (g_map_state == 0) {
if (!g_map_fetching) {
g_map_fetching = 1;
SceUID mt = sceKernelCreateThread("mapfetch",
mapfetch_thread, 0x10000100,
64 * 1024, 0, 0, NULL);
if (mt >= 0)
sceKernelStartThread(mt, 0, NULL);
else {
fetch_map_markers(); /* fallback */
g_map_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 map...");
if (pressed & SCE_CTRL_CIRCLE)
mode = MODE_GRID;
vita2d_end_drawing();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
continue;
}
if (g_map_state == -1) {
vita2d_start_drawing();
vita2d_clear_screen();
draw_centered(SCREEN_H / 2 - 10,
RGBA8(220, 120, 120, 255),
"Map unavailable (see log.txt)");
draw_centered(SCREEN_H / 2 + 24,
RGBA8(160, 160, 165, 255),
"O back");
if (pressed & SCE_CTRL_CIRCLE)
mode = MODE_GRID;
vita2d_end_drawing();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
continue;
}
if (!g_map_view_init) {
map_fit_view();
g_map_view_init = 1;
}
if (pressed & SCE_CTRL_CIRCLE) {
mode = MODE_GRID;
continue;
}
/* d-pad pans (held = continuous), L/R zoom about centre.
* pan step is in screen px, converted to merc by the scale
* so it feels the same at every zoom level. */
double pstep = 14.0 / g_map_scale;
if (pad.buttons & SCE_CTRL_LEFT) g_map_cx -= pstep;
if (pad.buttons & SCE_CTRL_RIGHT) g_map_cx += pstep;
if (pad.buttons & SCE_CTRL_UP) g_map_cy -= pstep;
if (pad.buttons & SCE_CTRL_DOWN) g_map_cy += pstep;
if (pad.buttons & SCE_CTRL_RTRIGGER) g_map_scale *= 1.03;
if (pad.buttons & SCE_CTRL_LTRIGGER) g_map_scale /= 1.03;
if (pressed & SCE_CTRL_TRIANGLE) g_map_grid = !g_map_grid;
map_clamp_view();
/* touch: one finger drags the map, two fingers pinch-zoom,
* a tap acts on the bubble under it (zoom in, or open a
* lone photo) */
int tapped_cluster = -2; /* -2 none, -1 empty, >=0 bubble */
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum >= 2) {
float ax = td.report[0].x * 0.5f;
float ay = td.report[0].y * 0.5f;
float bx = td.report[1].x * 0.5f;
float by = td.report[1].y * 0.5f;
float dist = sqrtf((ax - bx) * (ax - bx) +
(ay - by) * (ay - by));
if (map_pinch > 1.0f && dist > 1.0f) {
g_map_scale *= dist / map_pinch;
map_clamp_view();
}
map_pinch = dist;
touch_active = 1;
touch_dragged = 1;
} else if (td.reportNum == 1) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
map_pinch = 0;
if (!touch_active) {
touch_active = 1;
touch_dragged = 0;
touch_start_x = tx;
touch_start_y = ty;
touch_x = tx;
touch_y = ty;
} else {
if (fabsf(tx - touch_start_x) > 10 ||
fabsf(ty - touch_start_y) > 10)
touch_dragged = 1;
if (touch_dragged) {
g_map_cx -= (tx - touch_x) /
g_map_scale;
g_map_cy -= (ty - touch_y) /
g_map_scale;
map_clamp_view();
}
touch_x = tx;
touch_y = ty;
}
} else {
if (touch_active && !touch_dragged) {
/* tap on the top-right "Back" + glyph
* returns to the grid */
if (touch_x > SCREEN_W - 110 &&
touch_y < 56) {
touch_active = 0;
mode = MODE_GRID;
continue;
}
tapped_cluster =
map_cluster_at(touch_x, touch_y);
if (tapped_cluster < 0)
tapped_cluster = -1;
}
touch_active = 0;
map_pinch = 0;
}
}
map_cluster();
int nc = map_nearest_cluster();
/* X acts on the bubble nearest the centre; a tap acts on
* the bubble under the finger. a lone photo opens in the
* detail view (if it's been paged into the library),
* a multi-photo bubble zooms in to split it apart. */
int act = -1;
if (pressed & SCE_CTRL_CROSS)
act = nc;
else if (tapped_cluster >= 0)
act = tapped_cluster;
if (act >= 0 && act < g_cl_count) {
if (g_cl_n[act] <= 1) {
/* a lone marker: open the photo directly */
int s;
if (map_open_marker(g_cl_rep[act], &s)) {
sel = s;
mode = MODE_DETAIL;
detail_from_map = 1;
zoom = 1.0f;
panx = pany = 0.0f;
slide_x = slide_goal = 0;
slide_anim = 0;
detail_idx = -1;
grid_last_sel = -1;
continue;
}
} else {
/* a bubble: show all its photos on the grid */
g_search_active = 0;
gallery_open(act);
sel = 0;
scroll = target = 0.0f;
grid_last_sel = -1;
mode = MODE_GRID;
continue;
}
}
vita2d_start_drawing();
vita2d_clear_screen();
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
MAP_OCEAN_BG); /* sea, not black */
map_draw_tiles();
if (g_map_grid)
map_draw_graticule();
map_draw_clusters(nc);
/* centre crosshair: shows what X will act on */
vita2d_draw_rectangle(SCREEN_W / 2.0f - 8, SCREEN_H / 2.0f,
16, 1, RGBA8(255, 255, 255, 90));
vita2d_draw_rectangle(SCREEN_W / 2.0f, SCREEN_H / 2.0f - 8,
1, 16, RGBA8(255, 255, 255, 90));
{
char mh[96];
snprintf(mh, sizeof(mh), "Places - %d geotagged",
g_map_count);
draw_text(12, 28, RGBA8(235, 235, 240, 255), 1.0f, mh);
/* top-right: "Back" label + O glyph, as on other pages */
float bkcx = SCREEN_W - 30, bkcy = 36, bkd = 36;
int bw = text_width(0.95f, "Back");
draw_text(bkcx - bkd / 2 - 8 - bw, bkcy + 6,
RGBA8(220, 220, 225, 255), 0.95f, "Back");
draw_ps_button(bkcx, bkcy, bkd, ICON_CIRCLE,
RGBA8(235, 90, 85, 255), 26);
/* bottom-left: X opens the centred bubble */
draw_ps_button(28, SCREEN_H - 22, 30, ICON_CROSS,
RGBA8(120, 160, 235, 255), 22);
draw_text(48, SCREEN_H - 16,
RGBA8(220, 220, 225, 255), 0.9f, "Open");
/* bottom-right: L [zoom%] R (0% = whole world, 100% =
* max zoom). L and R are pinned and the number is
* centred in a fixed slot, so its changing width can't
* shuffle the L/R glyphs around. */
int zp = (int)(log(g_map_scale / MAP_SCALE_MIN) /
log(MAP_SCALE_MAX / MAP_SCALE_MIN) *
100.0 + 0.5);
if (zp < 0) zp = 0;
if (zp > 100) zp = 100;
char num[8];
snprintf(num, sizeof(num), "%d%%", zp);
uint32_t zc = RGBA8(220, 220, 225, 255);
float zy = SCREEN_H - 16, zright = SCREEN_W - 12;
int wL = text_width(0.9f, "L");
int wR = text_width(0.9f, "R");
int wMax = text_width(0.9f, "100%");
float gap = 10.0f;
float zx = zright - (wL + gap + wMax + gap + wR);
draw_text(zx, zy, zc, 0.9f, "L");
draw_text(zright - wR, zy, zc, 0.9f, "R");
int nw = text_width(0.9f, num);
float ncx = zx + wL + gap + wMax / 2.0f;
draw_text(ncx - nw / 2.0f, zy, zc, 0.9f, num);
}
vita2d_end_drawing();
vita2d_swap_buffers();
}
/* Pace the loop to the display (~60 Hz). vita2d's swap doesn't
* block on vblank, so without this the loop free-runs at
* whatever speed the host allows — which makes the auto-repeat
* race the selection forward absurdly fast, fires a page-fetch
* storm, and churns thumbnail textures hard enough to fault the
* GPU. Pacing it also makes the frame-count throttling above
* behave the same here as on real hardware. */
sceDisplayWaitVblankStart();
}
vita2d_wait_rendering_done();
for (int i = 0; i < g_texpool_n; i++) /* drain the recycle pool */
vita2d_free_texture(g_texpool[i]);
g_texpool_n = 0;
for (int k = 0; k < 2; k++)
if (pf_tex[k])
vita2d_free_texture(pf_tex[k]);
if (detail_tex)
vita2d_free_texture(detail_tex);
for (int i = 0; i < g_asset_count; i++)
if (g_thumb[i])
vita2d_free_texture(g_thumb[i]);
for (int i = 0; i < g_local_count; i++)
if (g_local_thumb[i])
vita2d_free_texture(g_local_thumb[i]);
if (g_ttf)
vita2d_free_font(g_ttf);
vita2d_free_pgf(g_font);
vita2d_fini();
curl_global_cleanup();
if (g_photo0_mounted)
sceAppMgrUmount("photo0:");
sceKernelExitProcess(0);
return 0;
}