Justified grid, photo swipe navigation, video posters, smart prefetch

Grid:
- justified rows like the Immich web timeline: widths follow each
  photo's exif aspect ratio (withExif on search/metadata, orientation
  aware), rows stretch to span the full screen width
- row-aware d-pad navigation (variable items per row)

Detail view:
- swipe horizontally to change photos, 1:1 with the finger, neighbor
  riding alongside; release past a quarter screen commits, less
  springs back; d-pad browsing uses the same slide animation
- touch drag pans 1:1 while zoomed
- neighbors prefetch (thumbs first, then full-res / video posters)
  so swipes land sharp; opening a cold item loads its low-res thumb
  before the full-res
- videos show their poster with a translucent play button instead of
  a "press X to play" text; tap or X plays; the gallery opens this
  screen rather than auto-playing

Video startup:
- the poster stays on screen through download progress, AVPlayer
  startup and the pre-first-frame gap; no more black screen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sads
2026-06-13 00:14:28 +02:00
co-authored by Claude Fable 5
parent aab839fae8
commit 82061c7377
+555 -95
View File
@@ -52,10 +52,9 @@
#define SCREEN_W 960 #define SCREEN_W 960
#define SCREEN_H 544 #define SCREEN_H 544
#define COLS 4 #define COLS 5 /* rough items per row, for prefetch distances only */
#define CELL_W 240 #define ROW_H 200 /* justified-grid row height; widths follow aspect */
#define CELL_H 240 #define CELL_PAD 3
#define CELL_PAD 6
#define HEADER_H 40 /* month/year band height in the grid */ #define HEADER_H 40 /* month/year band height in the grid */
#define THUMB_MAX 256 /* decode grid thumbs down to <= this dimension */ #define THUMB_MAX 256 /* decode grid thumbs down to <= this dimension */
#define FULL_MAX 4096 /* GXM texture size limit */ #define FULL_MAX 4096 /* GXM texture size limit */
@@ -91,6 +90,7 @@ static vita2d_pgf *g_font;
* buffers), so a realloc here can't dangle a pointer under it. */ * buffers), so a realloc here can't dangle a pointer under it. */
static char (*g_asset_ids)[40]; static char (*g_asset_ids)[40];
static char (*g_asset_dates)[DATELEN]; /* YYYY-MM-DDTHH:MM:SS */ static char (*g_asset_dates)[DATELEN]; /* YYYY-MM-DDTHH:MM:SS */
static float *g_asset_ratio; /* display w/h from exif, 0 = unknown */
static unsigned char *g_asset_is_video; static unsigned char *g_asset_is_video;
/* set during display rebuild: a backed-up local file matches this server /* set during display rebuild: a backed-up local file matches this server
* asset, so its grid cell shows the green "backed up" badge */ * asset, so its grid cell shows the green "backed up" badge */
@@ -147,10 +147,14 @@ static struct disp_item *g_disp;
static int g_disp_count; static int g_disp_count;
static int g_disp_cap; static int g_disp_cap;
/* per-display-slot grid layout (filled by layout_grid): pixel position of the /* per-display-slot grid layout (filled by layout_grid): pixel position and
* cell, plus the month/year section headers shown above each month's run */ * width/height of the cell (justified rows: width follows the photo's
* aspect, the row is stretched to span the full screen width), plus the
* month/year section headers above each month's run */
static float *g_item_x; static float *g_item_x;
static float *g_item_y; static float *g_item_y;
static float *g_item_w;
static float *g_item_h;
struct sect_hdr { float y; char label[24]; }; struct sect_hdr { float y; char label[24]; };
static struct sect_hdr *g_sect; static struct sect_hdr *g_sect;
static int g_sect_count; static int g_sect_count;
@@ -214,6 +218,7 @@ static int grow_assets(int need)
return 1; return 1;
if (!GROW(g_asset_ids, c, need) || if (!GROW(g_asset_ids, c, need) ||
!GROW(g_asset_dates, c, need) || !GROW(g_asset_dates, c, need) ||
!GROW(g_asset_ratio, c, need) ||
!GROW(g_asset_is_video, c, need) || !GROW(g_asset_is_video, c, need) ||
!GROW(g_asset_local_backed, c, need) || !GROW(g_asset_local_backed, c, need) ||
!GROW(g_thumb, c, need) || !GROW(g_thumb, c, need) ||
@@ -253,6 +258,8 @@ static int grow_disp(int need)
return 1; return 1;
if (!GROW(g_disp, c, need) || if (!GROW(g_disp, c, need) ||
!GROW(g_item_x, c, need) || !GROW(g_item_x, c, need) ||
!GROW(g_item_w, c, need) ||
!GROW(g_item_h, c, need) ||
!(g_disp_cap = GROW(g_item_y, c, need))) !(g_disp_cap = GROW(g_item_y, c, need)))
return 0; return 0;
return 1; return 1;
@@ -787,30 +794,57 @@ static int parse_assets(const char *js, size_t jslen, char *err, size_t errlen)
} }
/* walk tokens in document order; keys whose parent is the current /* walk tokens in document order; keys whose parent is the current
* top-level object belong to that asset (nested objects have a * top-level object belong to that asset (exifInfo is the one nested
* different parent, so owner.id etc. are skipped) */ * object we descend into, for the photo's display aspect ratio) */
int added = 0; int added = 0;
int cur = -1, cur_obj = -1; int cur = -1, cur_obj = -1, exif_obj = -1;
int exw = 0, exh = 0, exori = 0;
for (int i = arr_idx + 1; i < ntok; i++) { for (int i = arr_idx + 1; i < ntok; i++) {
if (tok[i].type == JSMN_OBJECT && tok[i].parent == arr_idx) { if (tok[i].type == JSMN_OBJECT && tok[i].parent == arr_idx) {
if (cur >= 0 && exw > 0 && exh > 0)
g_asset_ratio[cur] = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
if (!grow_assets(g_asset_count + 1)) if (!grow_assets(g_asset_count + 1))
break; /* out of memory: keep what we have */ break; /* out of memory: keep what we have */
cur = g_asset_count++; cur = g_asset_count++;
cur_obj = i; cur_obj = i;
exif_obj = -1;
exw = exh = exori = 0;
added++; added++;
g_asset_ids[cur][0] = '\0'; g_asset_ids[cur][0] = '\0';
g_asset_dates[cur][0] = '\0'; g_asset_dates[cur][0] = '\0';
g_asset_ratio[cur] = 0.0f;
g_asset_is_video[cur] = 0; g_asset_is_video[cur] = 0;
continue; continue;
} }
if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 || if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 ||
tok[i].parent != cur_obj || i + 1 >= ntok) i + 1 >= ntok)
continue; continue;
int klen = tok[i].end - tok[i].start; int klen = tok[i].end - tok[i].start;
const char *k = js + tok[i].start; const char *k = js + tok[i].start;
jsmntok_t *val = &tok[i + 1]; jsmntok_t *val = &tok[i + 1];
int vlen = val->end - val->start; int vlen = val->end - val->start;
if (tok[i].parent == exif_obj) {
/* numbers may also arrive as strings; atoi covers both
* and yields 0 on null */
if (klen == 14 && !strncmp(k, "exifImageWidth", 14))
exw = atoi(js + val->start);
else if (klen == 15 && !strncmp(k, "exifImageHeight", 15))
exh = atoi(js + val->start);
else if (klen == 11 && !strncmp(k, "orientation", 11))
exori = atoi(js + val->start);
continue;
}
if (tok[i].parent != cur_obj)
continue;
if (klen == 8 && !strncmp(k, "exifInfo", 8) &&
val->type == JSMN_OBJECT) {
exif_obj = i + 1;
continue;
}
if (val->type != JSMN_STRING) if (val->type != JSMN_STRING)
continue; continue;
@@ -828,6 +862,9 @@ static int parse_assets(const char *js, size_t jslen, char *err, size_t errlen)
g_asset_is_video[cur] = g_asset_is_video[cur] =
(vlen == 5 && !strncmp(js + val->start, "VIDEO", 5)); (vlen == 5 && !strncmp(js + val->start, "VIDEO", 5));
} }
if (cur >= 0 && exw > 0 && exh > 0)
g_asset_ratio[cur] = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
free(tok); free(tok);
/* drop trailing entries that somehow lack an id */ /* drop trailing entries that somehow lack an id */
@@ -848,7 +885,7 @@ static int fetch_page(int is_first)
snprintf(url, sizeof(url), "%s/api/search/metadata", g_server); snprintf(url, sizeof(url), "%s/api/search/metadata", g_server);
char body[128]; char body[128];
snprintf(body, sizeof(body), snprintf(body, sizeof(body),
"{\"page\":%d,\"size\":%d,\"order\":\"desc\"}", "{\"page\":%d,\"size\":%d,\"order\":\"desc\",\"withExif\":true}",
g_next_page, PAGE_SIZE); g_next_page, PAGE_SIZE);
membuf buf; membuf buf;
@@ -890,7 +927,11 @@ static int fetch_page(int is_first)
/* a short page means we reached the end of the library */ /* a short page means we reached the end of the library */
g_next_page = (added == PAGE_SIZE) ? g_next_page + 1 : 0; g_next_page = (added == PAGE_SIZE) ? g_next_page + 1 : 0;
log_line("page fetched: +%d assets (total %d)", added, g_asset_count); int with_ratio = 0;
for (int i = g_asset_count - added; i < g_asset_count; i++)
with_ratio += (g_asset_ratio[i] > 0.0f);
log_line("page fetched: +%d assets (total %d, %d with exif ratio)",
added, g_asset_count, with_ratio);
return added; return added;
} }
@@ -1291,14 +1332,51 @@ static void month_label(const char *date, char *out, size_t len)
snprintf(out, len, "%s %d", mon[m], y); snprintf(out, len, "%s %d", mon[m], y);
} }
/* compute each display slot's grid cell + the month/year header bands. the /* display aspect ratio (w/h) of a slot; local camera media and assets the
* timeline is already date-desc sorted, so a run of equal "YYYY-MM" is one * server reported no exif for fall back to 4:3 */
* month; every month starts on a fresh row under its own header. */ static float disp_ratio(int d)
{
struct disp_item *it = &g_disp[d];
float r = (it->src == SRC_SERVER) ? g_asset_ratio[it->idx] : 0.0f;
return r > 0.0f ? r : 4.0f / 3.0f;
}
/* place row items [start, end) at *y. when justifying, the whole row is
* scaled so it spans the full screen width (the row height grows with it,
* capped so a sparse row can't blow up); a month's trailing partial row
* stays at its natural size, left-aligned. */
static void layout_finish_row(int start, int end, float *y, float natw,
int justify)
{
float scale = 1.0f;
if (justify && natw > 0.0f) {
scale = (float)SCREEN_W / natw;
if (scale > 1.55f)
scale = 1.55f;
}
float h = ROW_H * scale;
float x = 0.0f;
for (int i = start; i < end; i++) {
g_item_x[i] = x;
g_item_y[i] = *y;
g_item_w[i] *= scale;
g_item_h[i] = h;
x += g_item_w[i];
}
*y += h;
}
/* compute each display slot's grid cell + the month/year header bands.
* justified rows like the Immich web timeline: each item's width follows
* its aspect ratio, a row wraps when the next item no longer fits, and the
* closed row is stretched to fill the full screen width. the timeline is
* date-desc sorted, so a run of equal "YYYY-MM" is one month; every month
* starts on a fresh row under its own header. */
static void layout_grid(void) static void layout_grid(void)
{ {
g_sect_count = 0; g_sect_count = 0;
float y = 0.0f; float y = 0.0f, natw = 0.0f;
int col = 0; int row_start = 0;
char curkey[8] = ""; char curkey[8] = "";
for (int d = 0; d < g_disp_count; d++) { for (int d = 0; d < g_disp_count; d++) {
@@ -1307,10 +1385,10 @@ static void layout_grid(void)
snprintf(key, sizeof(key), "%.7s", date); /* YYYY-MM */ snprintf(key, sizeof(key), "%.7s", date); /* YYYY-MM */
if (strcmp(key, curkey) != 0) { if (strcmp(key, curkey) != 0) {
if (col != 0) { /* finish the partial last row */ if (d > row_start) /* month's partial last row */
y += CELL_H; layout_finish_row(row_start, d, &y, natw, 0);
col = 0; row_start = d;
} natw = 0.0f;
if (grow_sect(g_sect_count + 1)) { if (grow_sect(g_sect_count + 1)) {
g_sect[g_sect_count].y = y; g_sect[g_sect_count].y = y;
month_label(date, g_sect[g_sect_count].label, month_label(date, g_sect[g_sect_count].label,
@@ -1321,15 +1399,21 @@ static void layout_grid(void)
snprintf(curkey, sizeof(curkey), "%s", key); snprintf(curkey, sizeof(curkey), "%s", key);
} }
g_item_x[d] = (float)col * CELL_W; float w = ROW_H * disp_ratio(d);
g_item_y[d] = y; if (w < ROW_H * 0.4f)
if (++col == COLS) { w = ROW_H * 0.4f; /* keep extreme portraits tappable */
col = 0; if (w > SCREEN_W)
y += CELL_H; w = SCREEN_W; /* panoramas: one per row */
if (natw > 0.0f && natw + w > SCREEN_W) {
layout_finish_row(row_start, d, &y, natw, 1);
row_start = d;
natw = 0.0f;
} }
g_item_w[d] = w; /* natural width; scaled when the row closes */
natw += w;
} }
if (col != 0) if (g_disp_count > row_start)
y += CELL_H; layout_finish_row(row_start, g_disp_count, &y, natw, 0);
g_content_h = y; g_content_h = y;
} }
@@ -1411,20 +1495,52 @@ static int item_at(float x, float wy)
for (int i = 0; i < g_disp_count; i++) { for (int i = 0; i < g_disp_count; i++) {
if (g_item_y[i] > wy) if (g_item_y[i] > wy)
break; /* item y is non-decreasing */ break; /* item y is non-decreasing */
if (wy < g_item_y[i] + CELL_H && if (wy < g_item_y[i] + g_item_h[i] &&
x >= g_item_x[i] && x < g_item_x[i] + CELL_W) x >= g_item_x[i] && x < g_item_x[i] + g_item_w[i])
return i; return i;
} }
return -1; return -1;
} }
/* move the selection one row up (dir<0) or down (dir>0), landing on the
* item whose cell is horizontally nearest — rows hold a variable number
* of items now, so the fixed columns arithmetic no longer applies */
static int nav_row(int sel, int dir)
{
if (sel < 0 || sel >= g_disp_count)
return sel;
float cy = g_item_y[sel];
float cx = g_item_x[sel] + g_item_w[sel] / 2.0f;
int i = sel;
/* step off the current row */
while (i + dir >= 0 && i + dir < g_disp_count &&
g_item_y[i] == cy)
i += dir;
if (g_item_y[i] == cy)
return sel; /* already on the first/last row */
/* pick the horizontally nearest item of that row */
float ry = g_item_y[i];
int best = i;
float bestd = -1.0f;
for (; i >= 0 && i < g_disp_count && g_item_y[i] == ry; i += dir) {
float d = g_item_x[i] + g_item_w[i] / 2.0f - cx;
if (d < 0)
d = -d;
if (bestd < 0 || d < bestd) {
bestd = d;
best = i;
}
}
return best;
}
/* display slot nearest a world-y (used to keep the selection inside the /* display slot nearest a world-y (used to keep the selection inside the
* viewport while a touch drag/fling moves the grid) */ * viewport while a touch drag/fling moves the grid) */
static int item_near(float wy) static int item_near(float wy)
{ {
int last = -1; int last = -1;
for (int i = 0; i < g_disp_count; i++) { for (int i = 0; i < g_disp_count; i++) {
if (g_item_y[i] + CELL_H > wy) if (g_item_y[i] + g_item_h[i] > wy)
return i; return i;
last = i; last = i;
} }
@@ -1459,6 +1575,27 @@ static int pick_next_load(int sel, int first_vis, int last_vis)
return -1; return -1;
} }
/* hand the worker a thumbnail request for display slot d (g_req_state must
* be REQ_IDLE); local videos get their poster extracted */
static void req_issue_thumb(int d)
{
struct disp_item *ti = &g_disp[d];
g_req_idx = ti->idx;
g_req_src = ti->src;
g_req_detail = 0;
if (ti->src == SRC_LOCAL) {
snprintf(g_req_path, sizeof(g_req_path), "%s",
g_local_path[ti->idx]);
g_req_is_video = g_local_is_video[ti->idx];
} else {
snprintf(g_req_id, sizeof(g_req_id), "%s",
g_asset_ids[ti->idx]);
g_req_is_video = 0;
}
__sync_synchronize();
g_req_state = REQ_PENDING;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* UI */ /* UI */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -1519,6 +1656,22 @@ static void draw_hud(const char *text)
RGBA8(255, 255, 255, 255), 1.0f, text); RGBA8(255, 255, 255, 255), 1.0f, text);
} }
/* translucent play button over a video's poster in the detail view */
static void draw_play_overlay(void)
{
float cx = SCREEN_W / 2.0f, cy = SCREEN_H / 2.0f;
vita2d_draw_fill_circle(cx, cy, 46.0f, RGBA8(20, 20, 20, 150));
vita2d_color_vertex *v =
vita2d_pool_memalign(3 * sizeof(*v), sizeof(*v));
if (!v)
return;
unsigned int col = RGBA8(255, 255, 255, 220);
v[0] = (vita2d_color_vertex){ cx - 14.0f, cy - 24.0f, 0.5f, col };
v[1] = (vita2d_color_vertex){ cx - 14.0f, cy + 24.0f, 0.5f, col };
v[2] = (vita2d_color_vertex){ cx + 28.0f, cy, 0.5f, col };
vita2d_draw_array(SCE_GXM_PRIMITIVE_TRIANGLES, v, 3);
}
static void draw_error_detail(int idx) static void draw_error_detail(int idx)
{ {
draw_centered(220, RGBA8(255, 80, 80, 255), "Failed to load this photo"); draw_centered(220, RGBA8(255, 80, 80, 255), "Failed to load this photo");
@@ -1570,6 +1723,25 @@ static size_t file_write_cb(void *ptr, size_t size, size_t nmemb, void *ud)
} }
static uint64_t g_dl_last_draw; static uint64_t g_dl_last_draw;
/* poster/thumb drawn behind the download progress and the player's startup
* (the seconds AVPlayer spends parsing before the first frame); set by the
* play call sites, valid for the whole modal download + playback */
static vita2d_texture *g_dl_bg;
/* full-screen status with the video's poster behind it */
static void show_video_status(const char *text)
{
vita2d_start_drawing();
vita2d_clear_screen();
if (g_dl_bg) {
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
RGBA8(0, 0, 0, 130));
}
draw_centered(SCREEN_H / 2, RGBA8(255, 255, 255, 255), text);
vita2d_end_drawing();
vita2d_swap_buffers();
}
static int dl_progress_cb(void *ud, curl_off_t dltotal, curl_off_t dlnow, static int dl_progress_cb(void *ud, curl_off_t dltotal, curl_off_t dlnow,
curl_off_t ultotal, curl_off_t ulnow) curl_off_t ultotal, curl_off_t ulnow)
@@ -1595,6 +1767,12 @@ static int dl_progress_cb(void *ud, curl_off_t dltotal, curl_off_t dlnow,
vita2d_start_drawing(); vita2d_start_drawing();
vita2d_clear_screen(); vita2d_clear_screen();
if (g_dl_bg) {
/* the video's poster behind the progress, dimmed */
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
RGBA8(0, 0, 0, 130));
}
draw_centered(SCREEN_H / 2 - 20, RGBA8(255, 255, 255, 255), buf); draw_centered(SCREEN_H / 2 - 20, RGBA8(255, 255, 255, 255), buf);
if (dltotal > 0) { if (dltotal > 0) {
float frac = (float)dlnow / (float)dltotal; float frac = (float)dlnow / (float)dltotal;
@@ -1931,7 +2109,7 @@ static void play_video_file(const char *path)
g_player_active = 1; g_player_active = 1;
__sync_synchronize(); __sync_synchronize();
for (int i = 0; i < 720 && g_poster_active; i++) for (int i = 0; i < 720 && g_poster_active; i++)
show_status("Starting video..."); show_video_status("Starting video...");
/* replay re-inits the player from the same (already-local) file, so the /* replay re-inits the player from the same (already-local) file, so the
* decoder claim above is taken once and held across replays. */ * decoder claim above is taken once and held across replays. */
@@ -1988,7 +2166,7 @@ replay:
/* AddSource parses asynchronously; give it ~5 s to start */ /* AddSource parses asynchronously; give it ~5 s to start */
int active = 0; int active = 0;
for (int i = 0; i < 300 && !(active = sceAvPlayerIsActive(g_avp)); i++) for (int i = 0; i < 300 && !(active = sceAvPlayerIsActive(g_avp)); i++)
show_status("Starting video..."); show_video_status("Starting video...");
/* AddSource parses asynchronously, so the stream duration is usually not /* AddSource parses asynchronously, so the stream duration is usually not
* available yet at this point; keep re-querying it in the loop below until * available yet at this point; keep re-querying it in the loop below until
@@ -2056,6 +2234,9 @@ replay:
vita2d_clear_screen(); vita2d_clear_screen();
if (cur) if (cur)
draw_texture_fitted(cur, 0, 0, SCREEN_W, SCREEN_H); draw_texture_fitted(cur, 0, 0, SCREEN_W, SCREEN_H);
else if (g_dl_bg)
/* poster until the first decoded frame arrives */
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
if (duration == 0) { if (duration == 0) {
SceAvPlayerStreamInfo sinfo; SceAvPlayerStreamInfo sinfo;
@@ -2136,6 +2317,12 @@ replay:
/* download + play asset idx, cleaning up the temp file afterwards */ /* download + play asset idx, cleaning up the temp file afterwards */
static void view_video(int idx) static void view_video(int idx)
{ {
/* the video's poster behind the download progress; the detail view
* pre-sets its sharper full-res poster, the grid falls back to the
* asset thumb. the modal download keeps the texture alive. */
if (!g_dl_bg)
g_dl_bg = g_thumb[idx];
char err[160]; char err[160];
int r = download_video(idx, err, sizeof(err)); int r = download_video(idx, err, sizeof(err));
if (r == 0) { if (r == 0) {
@@ -2145,6 +2332,7 @@ static void view_video(int idx)
log_line("video %s: %s", g_asset_ids[idx], err); log_line("video %s: %s", g_asset_ids[idx], err);
show_blocking_error("Failed to download video", err); show_blocking_error("Failed to download video", err);
} }
g_dl_bg = NULL;
sceIoRemove(VIDEO_TMP_PATH); sceIoRemove(VIDEO_TMP_PATH);
} }
@@ -2864,13 +3052,29 @@ int main(void)
int detail_issued = 0; /* the worker request has been handed over */ int detail_issued = 0; /* the worker request has been handed over */
float zoom = 1.0f, panx = 0.0f, pany = 0.0f; float zoom = 1.0f, panx = 0.0f, pany = 0.0f;
/* front-touch state (grid: drag scrolls, tap opens) */ /* 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_active = 0; /* finger currently down */
int touch_dragged = 0; /* moved past the tap threshold */ int touch_dragged = 0; /* moved past the tap threshold */
float touch_x = 0, touch_y = 0; /* last position, screen px */ float touch_x = 0, touch_y = 0; /* last position, screen px */
float touch_start_y = 0, touch_start_scroll = 0; 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 touch_vel = 0; /* px/frame at the moment of release */
/* 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 (;;) { for (;;) {
SceCtrlData pad; SceCtrlData pad;
sceCtrlPeekBufferPositive(0, &pad, 1); sceCtrlPeekBufferPositive(0, &pad, 1);
@@ -2898,6 +3102,16 @@ int main(void)
if (sel >= g_disp_count) if (sel >= g_disp_count)
sel = g_disp_count - 1; sel = g_disp_count - 1;
detail_idx = -1; /* re-decode: item under sel may differ */ 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;
}
} }
/* SELECT toggles the sync overview from the grid / detail view */ /* SELECT toggles the sync overview from the grid / detail view */
@@ -2920,14 +3134,14 @@ int main(void)
sel++; sel++;
if (nav & SCE_CTRL_LEFT) if (nav & SCE_CTRL_LEFT)
sel--; sel--;
if (nav & SCE_CTRL_DOWN)
sel += COLS;
if (nav & SCE_CTRL_UP)
sel -= COLS;
if (sel < 0) if (sel < 0)
sel = 0; sel = 0;
if (sel >= g_disp_count) if (sel >= g_disp_count)
sel = g_disp_count - 1; sel = g_disp_count - 1;
if (nav & SCE_CTRL_DOWN)
sel = nav_row(sel, +1);
if (nav & SCE_CTRL_UP)
sel = nav_row(sel, -1);
if (pressed & SCE_CTRL_RTRIGGER) if (pressed & SCE_CTRL_RTRIGGER)
sel = month_jump(sel, +1); /* older month */ sel = month_jump(sel, +1); /* older month */
if (pressed & SCE_CTRL_LTRIGGER) if (pressed & SCE_CTRL_LTRIGGER)
@@ -2953,27 +3167,14 @@ int main(void)
int scrolling_fast = (grid_settle < 12); int scrolling_fast = (grid_settle < 12);
if (pressed & SCE_CTRL_CROSS) { if (pressed & SCE_CTRL_CROSS) {
if (g_disp_count > 0 && disp_is_video(sel)) { /* photos and videos both open the detail
/* videos start loading/playing right * view; a video shows its poster with the
* away; no "press X to play" stop */ * play button there */
struct disp_item it = g_disp[sel];
if (it.src == SRC_LOCAL)
play_video_file(g_local_path[it.idx]);
else
view_video(it.idx);
/* wait for the player's buttons to be
* released so they don't also act on
* the grid */
do {
sceCtrlPeekBufferPositive(0, &pad, 1);
sceKernelDelayThread(10 * 1000);
} while (pad.buttons);
prev_buttons = 0;
continue;
}
mode = MODE_DETAIL; mode = MODE_DETAIL;
zoom = 1.0f; zoom = 1.0f;
panx = pany = 0.0f; panx = pany = 0.0f;
slide_x = slide_goal = 0;
slide_anim = 0;
} }
/* fetch the next page when the selection settles near the /* fetch the next page when the selection settles near the
@@ -3031,27 +3232,18 @@ int main(void)
} else if (touch_active) { } else if (touch_active) {
touch_active = 0; touch_active = 0;
if (!touch_dragged) { if (!touch_dragged) {
/* tap: open the item under it */ /* tap: open the item under it
* (videos show their poster +
* play button in the detail) */
int i = item_at(touch_x, int i = item_at(touch_x,
touch_y + scroll); touch_y + scroll);
if (i >= 0) { if (i >= 0) {
sel = i; sel = i;
if (disp_is_video(i)) {
struct disp_item ti = g_disp[i];
if (ti.src == SRC_LOCAL)
play_video_file(g_local_path[ti.idx]);
else
view_video(ti.idx);
do {
sceCtrlPeekBufferPositive(0, &pad, 1);
sceKernelDelayThread(10 * 1000);
} while (pad.buttons);
prev_buttons = 0;
continue;
}
mode = MODE_DETAIL; mode = MODE_DETAIL;
zoom = 1.0f; zoom = 1.0f;
panx = pany = 0.0f; panx = pany = 0.0f;
slide_x = slide_goal = 0;
slide_anim = 0;
} }
} else if (touch_vel > 2.0f || } else if (touch_vel > 2.0f ||
touch_vel < -2.0f) { touch_vel < -2.0f) {
@@ -3078,8 +3270,10 @@ int main(void)
g_item_y[sel] : 0.0f; g_item_y[sel] : 0.0f;
if (sel_y - HEADER_H < target) if (sel_y - HEADER_H < target)
target = sel_y - HEADER_H; target = sel_y - HEADER_H;
if (sel_y + CELL_H > target + SCREEN_H) float sel_h = (sel >= 0 && sel < g_disp_count) ?
target = sel_y + CELL_H - SCREEN_H; 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; float max_scroll = g_content_h - SCREEN_H;
if (max_scroll < 0) if (max_scroll < 0)
max_scroll = 0; max_scroll = 0;
@@ -3098,7 +3292,7 @@ int main(void)
* are recycled through the pool, see tex_release */ * are recycled through the pool, see tex_release */
for (int i = 0; i < g_disp_count; i++) { for (int i = 0; i < g_disp_count; i++) {
float dy = g_item_y[i] - scroll; float dy = g_item_y[i] - scroll;
if (dy > -CELL_H && dy < SCREEN_H) { if (dy > -g_item_h[i] && dy < SCREEN_H) {
if (first_vis < 0) if (first_vis < 0)
first_vis = i; first_vis = i;
last_vis = i; last_vis = i;
@@ -3178,8 +3372,8 @@ int main(void)
float x = g_item_x[i]; float x = g_item_x[i];
float y = g_item_y[i] - scroll; float y = g_item_y[i] - scroll;
float bx = x + CELL_PAD, by = y + CELL_PAD; float bx = x + CELL_PAD, by = y + CELL_PAD;
float bw = CELL_W - 2 * CELL_PAD; float bw = g_item_w[i] - 2 * CELL_PAD;
float bh = CELL_H - 2 * CELL_PAD; float bh = g_item_h[i] - 2 * CELL_PAD;
vita2d_texture *th = disp_thumb(i); vita2d_texture *th = disp_thumb(i);
if (th) if (th)
@@ -3202,7 +3396,8 @@ int main(void)
draw_status_badge(bx, by, i, frame); draw_status_badge(bx, by, i, frame);
if (i == sel) if (i == sel)
draw_sel_outline(x + 2, y + 2, draw_sel_outline(x + 2, y + 2,
CELL_W - 4, CELL_H - 4); g_item_w[i] - 4,
g_item_h[i] - 4);
} }
char hud[160]; char hud[160];
@@ -3246,11 +3441,106 @@ int main(void)
if (pad.buttons & SCE_CTRL_LEFT) 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_UP) pany += 12.0f;
if (pad.buttons & SCE_CTRL_DOWN) 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 { } else {
if (nav & SCE_CTRL_RIGHT && sel < g_disp_count - 1) 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);
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++; sel++;
if (nav & SCE_CTRL_LEFT && sel > 0) else if (slide_goal > 1.0f && sel > 0)
sel--; sel--;
slide_x = 0;
slide_goal = 0;
slide_anim = 0;
}
} }
if (pressed & SCE_CTRL_CIRCLE) { if (pressed & SCE_CTRL_CIRCLE) {
/* back to the grid; if a full-res load is in /* back to the grid; if a full-res load is in
@@ -3258,16 +3548,28 @@ int main(void)
mode = MODE_GRID; mode = MODE_GRID;
detail_idx = -1; detail_idx = -1;
detail_loading = 0; 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; continue;
} }
if (pressed & SCE_CTRL_CROSS) { if (pressed & SCE_CTRL_CROSS) {
if (detail_failed) { if (is_video) {
detail_idx = -1; /* retry */ /* play even if the poster fetch failed */
} else if (is_video) { g_dl_bg = detail_tex ?
detail_tex : disp_thumb(sel);
if (is_local) if (is_local)
play_video_file(g_local_path[it.idx]); play_video_file(g_local_path[it.idx]);
else else
view_video(it.idx); view_video(it.idx);
g_dl_bg = NULL;
/* wait for the buttons used inside the /* wait for the buttons used inside the
* player to be released, so they don't * player to be released, so they don't
* also act on this screen */ * also act on this screen */
@@ -3278,6 +3580,8 @@ int main(void)
prev_buttons = 0; prev_buttons = 0;
continue; continue;
} }
if (detail_failed)
detail_idx = -1; /* retry */
} }
/* left/right may have moved the selection; refresh the /* left/right may have moved the selection; refresh the
@@ -3287,6 +3591,29 @@ int main(void)
is_local = (it.src == SRC_LOCAL); is_local = (it.src == SRC_LOCAL);
is_video = disp_is_video(sel); 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) { if (detail_idx != sel) {
zoom = 1.0f; /* reset view for the new photo */ zoom = 1.0f; /* reset view for the new photo */
panx = pany = 0.0f; panx = pany = 0.0f;
@@ -3298,22 +3625,53 @@ int main(void)
detail_tex = NULL; detail_tex = NULL;
} }
detail_failed = 0; detail_failed = 0;
/* photos load asynchronously on the worker; /* photos (and server videos, whose poster is
* the blown-up grid thumb shows meanwhile and * the same preview endpoint) load async on the
* O stays responsive (videos: X plays) */ * worker; the blown-up grid thumb shows
detail_loading = !is_video; * meanwhile and O stays responsive */
detail_loading = (!is_video ||
it.src == SRC_SERVER);
detail_issued = 0; detail_issued = 0;
detail_idx = sel; 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 */ /* drive the async full-res load */
if (detail_loading) { if (detail_loading) {
if (!detail_issued) { if (!detail_issued) {
if (g_req_state == REQ_DONE) { if (pf_pending < 0 &&
g_req_state == REQ_DONE) {
__sync_synchronize(); __sync_synchronize();
consume_worker_result(); consume_worker_result();
} }
if (g_req_state == REQ_IDLE) { 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_idx = it.idx;
g_req_src = it.src; g_req_src = it.src;
g_req_detail = 1; g_req_detail = 1;
@@ -3336,17 +3694,116 @@ int main(void)
detail_loading = 0; detail_loading = 0;
detail_failed = (detail_tex == NULL); 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_start_drawing();
vita2d_clear_screen(); vita2d_clear_screen();
if (detail_tex) { int sliding = (slide_anim ||
draw_texture_zoom(detail_tex, zoom, &panx, &pany); 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);
}
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 if (is_video) { } else if (is_video) {
draw_centered(SCREEN_H / 2, /* video: its poster with a play button (the
RGBA8(200, 200, 200, 255), * poster is the detail load for server videos,
"Press X to play this video"); * 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) { } else if (detail_loading) {
/* low-res grid thumb blown up while the full /* low-res grid thumb blown up while the full
* image is still on its way */ * image is still on its way */
@@ -3366,8 +3823,8 @@ int main(void)
"Failed to load this photo"); "Failed to load this photo");
} }
const char *xhint = detail_failed ? "X retry " : const char *xhint = is_video ? "X play " :
is_video ? "X play " : ""; detail_failed ? "X retry " : "";
char hud[200]; char hud[200];
if (zoom > 1.001f) if (zoom > 1.001f)
snprintf(hud, sizeof(hud), snprintf(hud, sizeof(hud),
@@ -3538,6 +3995,9 @@ int main(void)
for (int i = 0; i < g_texpool_n; i++) /* drain the recycle pool */ for (int i = 0; i < g_texpool_n; i++) /* drain the recycle pool */
vita2d_free_texture(g_texpool[i]); vita2d_free_texture(g_texpool[i]);
g_texpool_n = 0; g_texpool_n = 0;
for (int k = 0; k < 2; k++)
if (pf_tex[k])
vita2d_free_texture(pf_tex[k]);
if (detail_tex) if (detail_tex)
vita2d_free_texture(detail_tex); vita2d_free_texture(detail_tex);
for (int i = 0; i < g_asset_count; i++) for (int i = 0; i < g_asset_count; i++)