Opening a photo no longer blocks on a "Loading photo" screen: the grid thumbnail is drawn scaled to full screen while the worker thread downloads and decodes the full-resolution image, and O backs out to the grid at any time (an in-flight result is discarded there). The synchronous loaders (load_image, load_local_image, decode_jpeg) are gone; the worker request gained a `detail` flag that decodes to FULL_MAX instead of THUMB_MAX, and the texture-building half of consume_worker_result is shared via req_build_texture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3431 lines
108 KiB
C
3431 lines
108 KiB
C
/*
|
|
* vitaImmich — minimal proof-of-concept Immich client for the PS Vita.
|
|
*
|
|
* Reads server URL + API key from ux0:data/vitaimmich/config.txt and shows
|
|
* the library as a scrollable chronological grid (newest first).
|
|
* X opens a photo full screen, O goes back. Videos are downloaded to the
|
|
* memory card and played with SceAvPlayer (hardware MP4/H.264 decoding).
|
|
*
|
|
* It also scans the Vita's own camera media (ux0:picture, ux0:video),
|
|
* hashes it (SHA1) on a background sync thread, asks the server which files
|
|
* are already backed up (POST /api/assets/bulk-upload-check) and can upload
|
|
* the rest (POST /api/assets, multipart). Local files are merged into the
|
|
* same date-sorted grid with a per-cell status badge (cloud-only / local-
|
|
* only / backed-up). SELECT opens a sync overview where uploads are started.
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdarg.h>
|
|
#include <setjmp.h>
|
|
#include <malloc.h>
|
|
|
|
#include <psp2/appmgr.h>
|
|
#include <psp2/audioout.h>
|
|
#include <psp2/avplayer.h>
|
|
#include <psp2/ctrl.h>
|
|
#include <psp2/display.h>
|
|
#include <psp2/gxm.h>
|
|
#include <psp2/kernel/sysmem.h>
|
|
#include <psp2/io/fcntl.h>
|
|
#include <psp2/io/stat.h>
|
|
#include <psp2/io/dirent.h>
|
|
#include <psp2/rtc.h>
|
|
#include <psp2/kernel/processmgr.h>
|
|
#include <psp2/kernel/threadmgr.h>
|
|
#include <psp2/net/net.h>
|
|
#include <psp2/net/netctl.h>
|
|
#include <psp2/sysmodule.h>
|
|
|
|
#include <vita2d.h>
|
|
#include <curl/curl.h>
|
|
#include <jpeglib.h>
|
|
#include <openssl/crypto.h>
|
|
#include <openssl/sha.h>
|
|
|
|
#define JSMN_STATIC
|
|
#define JSMN_PARENT_LINKS
|
|
#include "jsmn.h"
|
|
|
|
#define SCREEN_W 960
|
|
#define SCREEN_H 544
|
|
|
|
#define COLS 4
|
|
#define CELL_W 240
|
|
#define CELL_H 240
|
|
#define CELL_PAD 6
|
|
#define HEADER_H 40 /* month/year band height in the grid */
|
|
#define THUMB_MAX 256 /* decode grid thumbs down to <= this dimension */
|
|
#define FULL_MAX 4096 /* GXM texture size limit */
|
|
|
|
#define PAGE_SIZE 100
|
|
#define DATELEN 20 /* "YYYY-MM-DDTHH:MM:SS" + NUL, sortable */
|
|
|
|
#define CONFIG_DIR "ux0:data/vitaimmich"
|
|
#define CONFIG_PATH CONFIG_DIR "/config.txt"
|
|
#define LOG_PATH CONFIG_DIR "/log.txt"
|
|
#define VIDEO_TMP_PATH CONFIG_DIR "/video.mp4"
|
|
|
|
/* give curl/jpeg decoding plenty of heap */
|
|
int _newlib_heap_size_user = 192 * 1024 * 1024;
|
|
|
|
static char g_server[512];
|
|
static char g_apikey[256];
|
|
static char g_serverip[64];
|
|
/* optional DNS pin ("host:port:ip") for routers without NAT loopback */
|
|
static struct curl_slist *g_resolve_list;
|
|
|
|
/* folders scanned for camera media (config syncdir=, with defaults), and
|
|
* a size cap so a movie collection in ux0:video isn't hashed/synced */
|
|
static char g_syncdirs[8][192];
|
|
static int g_syncdir_count;
|
|
static long g_syncmax_mb = 512;
|
|
|
|
static vita2d_pgf *g_font;
|
|
|
|
/* server-asset arrays. dynamically grown (doubling) as pages are fetched, so
|
|
* the library is bounded only by memory, not a fixed cap. grown on the main
|
|
* thread; the thumb worker never indexes these (it reads dedicated request
|
|
* buffers), so a realloc here can't dangle a pointer under it. */
|
|
static char (*g_asset_ids)[40];
|
|
static char (*g_asset_dates)[DATELEN]; /* YYYY-MM-DDTHH:MM:SS */
|
|
static unsigned char *g_asset_is_video;
|
|
/* set during display rebuild: a backed-up local file matches this server
|
|
* asset, so its grid cell shows the green "backed up" badge */
|
|
static unsigned char *g_asset_local_backed;
|
|
static int g_asset_count;
|
|
static int g_asset_cap;
|
|
static int g_next_page = 1; /* 0 = no more pages */
|
|
|
|
static vita2d_texture **g_thumb;
|
|
static int *g_thumb_failed;
|
|
static char (*g_tex_err)[160];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* local camera media + sync state */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* per-item sync state machine. transitions are written by the sync
|
|
* thread, except QUEUED which the main thread sets on a LOCAL_ONLY item
|
|
* (guarded the same way as the thumb worker's g_req_state handoff). */
|
|
enum {
|
|
SYNC_UNSCANNED, /* found on disk, not hashed yet */
|
|
SYNC_HASHING, /* sync thread is computing SHA1 */
|
|
SYNC_CHECKING, /* hashed, awaiting/under bulk-upload-check */
|
|
SYNC_LOCAL_ONLY, /* not on the server */
|
|
SYNC_QUEUED, /* user asked to upload (main-thread write) */
|
|
SYNC_UPLOADING, /* sync thread is POSTing it */
|
|
SYNC_BACKED_UP, /* exists on the server */
|
|
SYNC_FAILED, /* upload/check error (see g_local_err) */
|
|
};
|
|
|
|
/* local camera-media arrays. grown (doubling) during the one-shot startup
|
|
* scan, then fixed for the rest of the run, so the sync thread can read them
|
|
* without a realloc moving the storage out from under it. */
|
|
static char (*g_local_path)[256];
|
|
static char (*g_local_date)[DATELEN]; /* sortable mtime */
|
|
static char (*g_local_sha1)[41]; /* lowercase hex */
|
|
static char (*g_local_server_id)[40]; /* assetId once backed up */
|
|
static char (*g_local_err)[96];
|
|
static SceDateTime *g_local_mtime;
|
|
static SceDateTime *g_local_ctime;
|
|
static long long *g_local_size;
|
|
static unsigned char *g_local_is_video;
|
|
static volatile int *g_local_state;
|
|
static int g_local_count;
|
|
static int g_local_cap;
|
|
|
|
static vita2d_texture **g_local_thumb;
|
|
static int *g_local_thumb_failed;
|
|
|
|
/* merged, date-desc display order of server + local items */
|
|
enum { SRC_SERVER, SRC_LOCAL };
|
|
struct disp_item { unsigned char src; int idx; };
|
|
static struct disp_item *g_disp;
|
|
static int g_disp_count;
|
|
static int g_disp_cap;
|
|
|
|
/* per-display-slot grid layout (filled by layout_grid): pixel position of the
|
|
* cell, plus the month/year section headers shown above each month's run */
|
|
static float *g_item_x;
|
|
static float *g_item_y;
|
|
struct sect_hdr { float y; char label[24]; };
|
|
static struct sect_hdr *g_sect;
|
|
static int g_sect_count;
|
|
static int g_sect_cap;
|
|
static float g_content_h; /* total scrollable height incl. headers */
|
|
|
|
/* Texture recycling pool. Freeing a texture unmaps its memblock, and the
|
|
* GPU side (notably Vita3K's texture cache, which re-reads guest memory of
|
|
* cached textures at its own pace) may still touch it afterwards — freeing
|
|
* evicted thumbnails crashes intermittently no matter how long the free is
|
|
* deferred. So thumbnails are never freed while browsing: released textures
|
|
* go into this pool and get reused for the next thumb with the same
|
|
* dimensions+format (Immich previews come in a handful of sizes, so the hit
|
|
* rate is high and the pool stays small). */
|
|
#define TEXPOOL_MAX 160
|
|
static vita2d_texture *g_texpool[TEXPOOL_MAX];
|
|
static int g_texpool_n;
|
|
|
|
static void tex_release(vita2d_texture *tex);
|
|
static vita2d_texture *tex_acquire(int w, int h, SceGxmTextureFormat fmt);
|
|
|
|
/* the sync thread asks the main thread to rebuild g_disp (which the main
|
|
* thread also reads every frame) by setting this; one writer per side */
|
|
static volatile int g_need_rebuild;
|
|
|
|
/* sync thread -> sync overview screen (loose: status text only) */
|
|
static volatile int g_sync_phase; /* 0 idle, 1 hashing, 2 checking, 3 uploading */
|
|
static char g_sync_activity[160];
|
|
static volatile curl_off_t g_ul_now, g_ul_total;
|
|
static char g_sync_errlog[5][160];
|
|
static volatile int g_sync_errn;
|
|
|
|
static void log_line(const char *fmt, ...);
|
|
|
|
/* grow *pp to hold `need` elements of `elem` bytes, doubling from `cur` cap;
|
|
* zero the freshly added tail (callers rely on NULL thumbs / zero state).
|
|
* returns the new capacity, or 0 on allocation failure. */
|
|
static int grow_array(void **pp, int cur, int need, size_t elem)
|
|
{
|
|
if (need <= cur)
|
|
return cur;
|
|
int cap = cur ? cur : 64;
|
|
while (cap < need)
|
|
cap *= 2;
|
|
void *np = realloc(*pp, (size_t)cap * elem);
|
|
if (!np)
|
|
return 0;
|
|
memset((char *)np + (size_t)cur * elem, 0, (size_t)(cap - cur) * elem);
|
|
*pp = np;
|
|
return cap;
|
|
}
|
|
|
|
#define GROW(arr, capvar, need) \
|
|
grow_array((void **)&(arr), (capvar), (need), sizeof(*(arr)))
|
|
|
|
/* ensure the server-asset arrays hold at least `need` entries */
|
|
static int grow_assets(int need)
|
|
{
|
|
int c = g_asset_cap;
|
|
if (need <= c)
|
|
return 1;
|
|
if (!GROW(g_asset_ids, c, need) ||
|
|
!GROW(g_asset_dates, c, need) ||
|
|
!GROW(g_asset_is_video, c, need) ||
|
|
!GROW(g_asset_local_backed, c, need) ||
|
|
!GROW(g_thumb, c, need) ||
|
|
!GROW(g_thumb_failed, c, need) ||
|
|
!(g_asset_cap = GROW(g_tex_err, c, need)))
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
/* ensure the local-media arrays hold at least `need` entries */
|
|
static int grow_locals(int need)
|
|
{
|
|
int c = g_local_cap;
|
|
if (need <= c)
|
|
return 1;
|
|
if (!GROW(g_local_path, c, need) ||
|
|
!GROW(g_local_date, c, need) ||
|
|
!GROW(g_local_sha1, c, need) ||
|
|
!GROW(g_local_server_id, c, need) ||
|
|
!GROW(g_local_err, c, need) ||
|
|
!GROW(g_local_mtime, c, need) ||
|
|
!GROW(g_local_ctime, c, need) ||
|
|
!GROW(g_local_size, c, need) ||
|
|
!GROW(g_local_is_video, c, need) ||
|
|
!GROW(g_local_state, c, need) ||
|
|
!GROW(g_local_thumb, c, need) ||
|
|
!(g_local_cap = GROW(g_local_thumb_failed, c, need)))
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
/* the merged timeline + its per-slot grid positions share one capacity */
|
|
static int grow_disp(int need)
|
|
{
|
|
int c = g_disp_cap;
|
|
if (need <= c)
|
|
return 1;
|
|
if (!GROW(g_disp, c, need) ||
|
|
!GROW(g_item_x, c, need) ||
|
|
!(g_disp_cap = GROW(g_item_y, c, need)))
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
static int grow_sect(int need)
|
|
{
|
|
if (need <= g_sect_cap)
|
|
return 1;
|
|
int cap = GROW(g_sect, g_sect_cap, need);
|
|
if (!cap)
|
|
return 0;
|
|
g_sect_cap = cap;
|
|
return 1;
|
|
}
|
|
|
|
static void log_line(const char *fmt, ...)
|
|
{
|
|
FILE *f = fopen(LOG_PATH, "a");
|
|
if (!f)
|
|
return;
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vfprintf(f, fmt, ap);
|
|
va_end(ap);
|
|
fputc('\n', f);
|
|
fclose(f);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* drawing helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void draw_centered(int y, uint32_t color, const char *text)
|
|
{
|
|
int w = vita2d_pgf_text_width(g_font, 1.0f, text);
|
|
vita2d_pgf_draw_text(g_font, (SCREEN_W - w) / 2, y, color, 1.0f, text);
|
|
}
|
|
|
|
static void show_status(const char *fmt, ...)
|
|
{
|
|
char buf[512];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(buf, sizeof(buf), fmt, ap);
|
|
va_end(ap);
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
draw_centered(SCREEN_H / 2, RGBA8(255, 255, 255, 255), buf);
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
}
|
|
|
|
/* show an error (plus optional detail lines) until START is pressed, then exit */
|
|
static void fatal_error(const char *detail, const char *fmt, ...)
|
|
{
|
|
char buf[512];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(buf, sizeof(buf), fmt, ap);
|
|
va_end(ap);
|
|
|
|
log_line("FATAL: %s | %s", buf, detail ? detail : "-");
|
|
|
|
for (;;) {
|
|
SceCtrlData pad;
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
if (pad.buttons & SCE_CTRL_START)
|
|
break;
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
draw_centered(180, RGBA8(255, 80, 80, 255), buf);
|
|
if (detail && detail[0]) {
|
|
const int chars_per_line = 80;
|
|
int len = strlen(detail);
|
|
int y = 230;
|
|
for (int off = 0; off < len && y < 480; off += chars_per_line, y += 26) {
|
|
char line[96];
|
|
snprintf(line, sizeof(line), "%.*s", chars_per_line, detail + off);
|
|
vita2d_pgf_draw_text(g_font, 40, y, RGBA8(200, 200, 200, 255), 1.0f, line);
|
|
}
|
|
}
|
|
draw_centered(510, RGBA8(160, 160, 160, 255), "Press START to exit");
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
}
|
|
|
|
vita2d_fini();
|
|
sceKernelExitProcess(0);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* config */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* trim whitespace on both ends and a UTF-8 BOM; editors sneak these in,
|
|
* and any of them makes curl fail with "unsupported protocol" */
|
|
static char *clean_line(char *s)
|
|
{
|
|
if (!strncmp(s, "\xEF\xBB\xBF", 3))
|
|
s += 3;
|
|
while (*s == ' ' || *s == '\t')
|
|
s++;
|
|
size_t n = strlen(s);
|
|
while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r' ||
|
|
s[n - 1] == ' ' || s[n - 1] == '\t'))
|
|
s[--n] = '\0';
|
|
return s;
|
|
}
|
|
|
|
static int load_config(void)
|
|
{
|
|
FILE *f = fopen(CONFIG_PATH, "r");
|
|
if (!f) {
|
|
/* create a template so the user just has to edit it */
|
|
f = fopen(CONFIG_PATH, "w");
|
|
if (f) {
|
|
fputs("server=http://192.168.1.100:2283\n"
|
|
"apikey=PASTE_YOUR_IMMICH_API_KEY_HERE\n"
|
|
"# serverip=192.168.1.100 (optional: LAN IP of the\n"
|
|
"# server, for routers without NAT loopback)\n"
|
|
"# syncdir=ux0:picture (optional, repeatable: folders\n"
|
|
"# to scan for camera media; default ux0:picture and\n"
|
|
"# ux0:video/CAMERA)\n"
|
|
"# syncmaxmb=512 (skip files bigger than this many\n"
|
|
"# MB when syncing)\n", f);
|
|
fclose(f);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
char line[512];
|
|
while (fgets(line, sizeof(line), f)) {
|
|
char *s = clean_line(line);
|
|
if (!strncmp(s, "server=", 7))
|
|
snprintf(g_server, sizeof(g_server), "%s", clean_line(s + 7));
|
|
else if (!strncmp(s, "apikey=", 7))
|
|
snprintf(g_apikey, sizeof(g_apikey), "%s", clean_line(s + 7));
|
|
else if (!strncmp(s, "serverip=", 9))
|
|
snprintf(g_serverip, sizeof(g_serverip), "%s", clean_line(s + 9));
|
|
else if (!strncmp(s, "syncdir=", 8)) {
|
|
if (g_syncdir_count < (int)(sizeof(g_syncdirs) /
|
|
sizeof(g_syncdirs[0]))) {
|
|
char *d = clean_line(s + 8);
|
|
size_t dl = strlen(d);
|
|
while (dl > 0 && d[dl - 1] == '/')
|
|
d[--dl] = '\0';
|
|
if (dl > 0)
|
|
snprintf(g_syncdirs[g_syncdir_count++],
|
|
sizeof(g_syncdirs[0]), "%s", d);
|
|
}
|
|
} else if (!strncmp(s, "syncmaxmb=", 10))
|
|
g_syncmax_mb = atol(clean_line(s + 10));
|
|
}
|
|
fclose(f);
|
|
|
|
/* strip trailing slash from server URL */
|
|
size_t n = strlen(g_server);
|
|
if (n > 0 && g_server[n - 1] == '/')
|
|
g_server[n - 1] = '\0';
|
|
|
|
if (!g_server[0] || !g_apikey[0] || strstr(g_apikey, "PASTE_YOUR"))
|
|
return -1;
|
|
|
|
/* default to http:// when no scheme is given */
|
|
if (!strstr(g_server, "://")) {
|
|
char tmp[512];
|
|
snprintf(tmp, sizeof(tmp), "http://%s", g_server);
|
|
snprintf(g_server, sizeof(g_server), "%s", tmp);
|
|
}
|
|
if (strncasecmp(g_server, "http://", 7) && strncasecmp(g_server, "https://", 8))
|
|
fatal_error(g_server,
|
|
"Bad server URL in config (must start with http:// or https://):");
|
|
|
|
/* serverip= pins the hostname to a fixed IP (keeps Host header and
|
|
* TLS SNI intact, unlike putting the IP in the URL) */
|
|
if (g_serverip[0]) {
|
|
int https = !strncasecmp(g_server, "https://", 8);
|
|
const char *host = strstr(g_server, "://") + 3;
|
|
char hostname[256];
|
|
size_t i = 0;
|
|
while (host[i] && host[i] != '/' && host[i] != ':' &&
|
|
i < sizeof(hostname) - 1) {
|
|
hostname[i] = host[i];
|
|
i++;
|
|
}
|
|
hostname[i] = '\0';
|
|
int port = https ? 443 : 80;
|
|
if (host[i] == ':')
|
|
port = atoi(host + i + 1);
|
|
|
|
char resolve[400];
|
|
snprintf(resolve, sizeof(resolve), "%s:%d:%s",
|
|
hostname, port, g_serverip);
|
|
g_resolve_list = curl_slist_append(NULL, resolve);
|
|
log_line("resolve pin: %s", resolve);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* networking */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static char g_net_mem[1024 * 1024];
|
|
|
|
/* OpenSSL 1.0.2 is not thread-safe unless the application installs locking
|
|
* callbacks. Three threads here do concurrent HTTPS (main page fetches, the
|
|
* thumbnail worker, the sync thread); without these locks, overlapping TLS
|
|
* work corrupts OpenSSL's shared state and crashes deep inside the cipher
|
|
* (seen as wild reads/writes in CRYPTO_gcm128_decrypt while fast-scrolling,
|
|
* which is exactly when a page fetch overlaps thumbnail downloads). */
|
|
static SceUID *g_ssl_locks;
|
|
|
|
/* vitasdk's OpenSSL 1.0.2 is built without thread support (opensslconf.h
|
|
* defines no OPENSSL_THREADS), so even with locking callbacks installed,
|
|
* concurrent TLS on multiple threads corrupts its global state — crashes
|
|
* surface deep in the cipher (CRYPTO_gcm128_decrypt) when a page fetch on
|
|
* the main thread overlaps a thumbnail download on the worker. Serialize
|
|
* every curl_easy_perform behind this mutex instead. */
|
|
static SceUID g_net_mutex = -1;
|
|
|
|
static void net_lock(void)
|
|
{
|
|
if (g_net_mutex >= 0)
|
|
sceKernelLockMutex(g_net_mutex, 1, NULL);
|
|
}
|
|
|
|
static void net_unlock(void)
|
|
{
|
|
if (g_net_mutex >= 0)
|
|
sceKernelUnlockMutex(g_net_mutex, 1);
|
|
}
|
|
|
|
static void ssl_lock_cb(int mode, int n, const char *file, int line)
|
|
{
|
|
(void)file; (void)line;
|
|
if (mode & CRYPTO_LOCK)
|
|
sceKernelLockMutex(g_ssl_locks[n], 1, NULL);
|
|
else
|
|
sceKernelUnlockMutex(g_ssl_locks[n], 1);
|
|
}
|
|
|
|
static unsigned long ssl_thread_id_cb(void)
|
|
{
|
|
return (unsigned long)sceKernelGetThreadId();
|
|
}
|
|
|
|
static void ssl_locks_init(void)
|
|
{
|
|
int n = CRYPTO_num_locks();
|
|
g_ssl_locks = malloc(n * sizeof(SceUID));
|
|
if (!g_ssl_locks)
|
|
return;
|
|
for (int i = 0; i < n; i++) {
|
|
char name[32];
|
|
snprintf(name, sizeof(name), "ssl_lock_%d", i);
|
|
g_ssl_locks[i] = sceKernelCreateMutex(name, 0, 0, NULL);
|
|
}
|
|
CRYPTO_set_id_callback(ssl_thread_id_cb);
|
|
CRYPTO_set_locking_callback(ssl_lock_cb);
|
|
log_line("openssl locking callbacks installed (%d locks)", n);
|
|
}
|
|
|
|
static void net_init(void)
|
|
{
|
|
g_net_mutex = sceKernelCreateMutex("net_mutex", 0, 0, NULL);
|
|
ssl_locks_init();
|
|
sceSysmoduleLoadModule(SCE_SYSMODULE_NET);
|
|
if (sceNetShowNetstat() == SCE_NET_ERROR_ENOTINIT) {
|
|
SceNetInitParam param = {
|
|
.memory = g_net_mem,
|
|
.size = sizeof(g_net_mem),
|
|
.flags = 0,
|
|
};
|
|
sceNetInit(¶m);
|
|
}
|
|
sceNetCtlInit();
|
|
curl_global_init(CURL_GLOBAL_ALL);
|
|
}
|
|
|
|
typedef struct {
|
|
char *data;
|
|
size_t size;
|
|
} membuf;
|
|
|
|
static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
|
|
{
|
|
membuf *mb = userdata;
|
|
size_t add = size * nmemb;
|
|
char *p = realloc(mb->data, mb->size + add + 1);
|
|
if (!p)
|
|
return 0;
|
|
mb->data = p;
|
|
memcpy(mb->data + mb->size, ptr, add);
|
|
mb->size += add;
|
|
mb->data[mb->size] = '\0';
|
|
return add;
|
|
}
|
|
|
|
/* verbose-log the first request via curl's debug stream so connection
|
|
* problems land in log.txt with curl's own explanation */
|
|
static int g_verbose_request = 1;
|
|
|
|
static int curl_debug_cb(CURL *h, curl_infotype type, char *data,
|
|
size_t size, void *ud)
|
|
{
|
|
if (type == CURLINFO_TEXT && size > 0) {
|
|
char line[300];
|
|
if (data[size - 1] == '\n')
|
|
size--;
|
|
snprintf(line, sizeof(line), "%.*s", (int)size, data);
|
|
log_line("curl: %s", line);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* body == NULL -> GET, otherwise POST with a JSON body.
|
|
* ctype (optional) receives the response Content-Type. */
|
|
static CURLcode http_request(const char *url, const char *body,
|
|
membuf *out, long *http_code,
|
|
char *ctype, size_t ctype_len)
|
|
{
|
|
out->data = NULL;
|
|
out->size = 0;
|
|
*http_code = 0;
|
|
if (ctype)
|
|
ctype[0] = '\0';
|
|
|
|
CURL *curl = curl_easy_init();
|
|
if (!curl)
|
|
return CURLE_FAILED_INIT;
|
|
|
|
char keyhdr[300];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
|
|
struct curl_slist *hdrs = NULL;
|
|
hdrs = curl_slist_append(hdrs, keyhdr);
|
|
hdrs = curl_slist_append(hdrs, "Accept: application/json, image/jpeg");
|
|
if (body)
|
|
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
|
|
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "vitaImmich/0.1 (PS Vita)");
|
|
/* PoC: no CA bundle on the Vita, skip TLS verification */
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
|
if (g_resolve_list)
|
|
curl_easy_setopt(curl, CURLOPT_RESOLVE, g_resolve_list);
|
|
if (body)
|
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
|
if (g_verbose_request) {
|
|
curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_DEBUGFUNCTION, curl_debug_cb);
|
|
}
|
|
|
|
net_lock(); /* TLS is single-threaded, see g_net_mutex */
|
|
CURLcode res = curl_easy_perform(curl);
|
|
net_unlock();
|
|
if (g_verbose_request) {
|
|
log_line("curl: perform result=%d (%s)", res, curl_easy_strerror(res));
|
|
g_verbose_request = 0;
|
|
}
|
|
if (res == CURLE_OK) {
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
|
|
if (ctype) {
|
|
char *ct = NULL;
|
|
curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ct);
|
|
if (ct)
|
|
snprintf(ctype, ctype_len, "%s", ct);
|
|
}
|
|
}
|
|
|
|
curl_slist_free_all(hdrs);
|
|
curl_easy_cleanup(curl);
|
|
return res;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* JPEG decoding */
|
|
/* */
|
|
/* libjpeg directly instead of vita2d_load_JPEG_buffer: vita2d rejects */
|
|
/* any JPEG whose first marker isn't APP0/APP1, but Immich previews */
|
|
/* start with an APP2 ICC-profile marker (ff d8 ff e2). Also lets us */
|
|
/* downscale at decode time for grid thumbnails, and a longjmp error */
|
|
/* handler keeps corrupt data from exit()ing the app. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
struct jpeg_jmp_err {
|
|
struct jpeg_error_mgr mgr;
|
|
jmp_buf jb;
|
|
char msg[JMSG_LENGTH_MAX];
|
|
};
|
|
|
|
static void jpeg_jmp_error_exit(j_common_ptr cinfo)
|
|
{
|
|
struct jpeg_jmp_err *e = (struct jpeg_jmp_err *)cinfo->err;
|
|
cinfo->err->format_message(cinfo, e->msg);
|
|
longjmp(e->jb, 1);
|
|
}
|
|
|
|
|
|
/* decode into a tightly packed malloc'd pixel buffer; no vita2d/GXM
|
|
* calls, so this is safe to run on the loader thread */
|
|
static unsigned char *decode_jpeg_buf(const void *data, size_t size, int maxdim,
|
|
int *w, int *h, int *comps,
|
|
char *err, size_t errlen)
|
|
{
|
|
struct jpeg_decompress_struct ji;
|
|
struct jpeg_jmp_err jerr;
|
|
unsigned char *pix = NULL;
|
|
|
|
ji.err = jpeg_std_error(&jerr.mgr);
|
|
jerr.mgr.error_exit = jpeg_jmp_error_exit;
|
|
if (setjmp(jerr.jb)) {
|
|
snprintf(err, errlen, "libjpeg: %s", jerr.msg);
|
|
jpeg_destroy_decompress(&ji);
|
|
free(pix);
|
|
return NULL;
|
|
}
|
|
|
|
jpeg_create_decompress(&ji);
|
|
jpeg_mem_src(&ji, (void *)data, size);
|
|
jpeg_read_header(&ji, 1);
|
|
|
|
unsigned int longer = ji.image_width > ji.image_height ?
|
|
ji.image_width : ji.image_height;
|
|
ji.scale_num = 1;
|
|
ji.scale_denom = 1;
|
|
while (longer / ji.scale_denom > (unsigned int)maxdim && ji.scale_denom < 8)
|
|
ji.scale_denom *= 2;
|
|
|
|
jpeg_start_decompress(&ji);
|
|
|
|
if (ji.output_components != 1 && ji.output_components != 3) {
|
|
snprintf(err, errlen, "unsupported JPEG: %d components (colorspace %d)",
|
|
ji.output_components, ji.jpeg_color_space);
|
|
jpeg_abort_decompress(&ji);
|
|
jpeg_destroy_decompress(&ji);
|
|
return NULL;
|
|
}
|
|
|
|
unsigned int rowbytes = ji.output_width * ji.output_components;
|
|
pix = malloc(rowbytes * ji.output_height);
|
|
if (!pix) {
|
|
snprintf(err, errlen, "out of memory (%ux%u)",
|
|
ji.output_width, ji.output_height);
|
|
jpeg_abort_decompress(&ji);
|
|
jpeg_destroy_decompress(&ji);
|
|
return NULL;
|
|
}
|
|
|
|
unsigned char *row = pix;
|
|
while (ji.output_scanline < ji.output_height) {
|
|
jpeg_read_scanlines(&ji, &row, 1);
|
|
row += rowbytes;
|
|
}
|
|
|
|
*w = ji.output_width;
|
|
*h = ji.output_height;
|
|
*comps = ji.output_components;
|
|
jpeg_finish_decompress(&ji);
|
|
jpeg_destroy_decompress(&ji);
|
|
return pix;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* immich API */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* append assets from a search/metadata (or search/random) response */
|
|
static int parse_assets(const char *js, size_t jslen, char *err, size_t errlen)
|
|
{
|
|
jsmn_parser parser;
|
|
jsmn_init(&parser);
|
|
int ntok = jsmn_parse(&parser, js, jslen, NULL, 0);
|
|
if (ntok <= 0) {
|
|
snprintf(err, errlen, "JSON parse error (%d)", ntok);
|
|
return -1;
|
|
}
|
|
|
|
jsmntok_t *tok = malloc(sizeof(jsmntok_t) * ntok);
|
|
if (!tok) {
|
|
snprintf(err, errlen, "out of memory (%d tokens)", ntok);
|
|
return -1;
|
|
}
|
|
jsmn_init(&parser);
|
|
ntok = jsmn_parse(&parser, js, jslen, tok, ntok);
|
|
if (ntok <= 0) {
|
|
free(tok);
|
|
snprintf(err, errlen, "JSON parse error (%d)", ntok);
|
|
return -1;
|
|
}
|
|
|
|
/* locate the asset array: root itself, or assets.items */
|
|
int arr_idx = -1;
|
|
if (tok[0].type == JSMN_ARRAY) {
|
|
arr_idx = 0;
|
|
} else {
|
|
int assets_obj = -1;
|
|
for (int i = 1; i < ntok - 1; i++)
|
|
if (tok[i].type == JSMN_STRING && tok[i].size == 1 &&
|
|
tok[i].parent == 0 &&
|
|
tok[i].end - tok[i].start == 6 &&
|
|
!strncmp(js + tok[i].start, "assets", 6) &&
|
|
tok[i + 1].type == JSMN_OBJECT) {
|
|
assets_obj = i + 1;
|
|
break;
|
|
}
|
|
for (int i = assets_obj + 1; assets_obj > 0 && i < ntok - 1; i++)
|
|
if (tok[i].type == JSMN_STRING && tok[i].size == 1 &&
|
|
tok[i].parent == assets_obj &&
|
|
tok[i].end - tok[i].start == 5 &&
|
|
!strncmp(js + tok[i].start, "items", 5) &&
|
|
tok[i + 1].type == JSMN_ARRAY) {
|
|
arr_idx = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (arr_idx < 0) {
|
|
free(tok);
|
|
snprintf(err, errlen, "unexpected response shape");
|
|
return -1;
|
|
}
|
|
|
|
/* walk tokens in document order; keys whose parent is the current
|
|
* top-level object belong to that asset (nested objects have a
|
|
* different parent, so owner.id etc. are skipped) */
|
|
int added = 0;
|
|
int cur = -1, cur_obj = -1;
|
|
for (int i = arr_idx + 1; i < ntok; i++) {
|
|
if (tok[i].type == JSMN_OBJECT && tok[i].parent == arr_idx) {
|
|
if (!grow_assets(g_asset_count + 1))
|
|
break; /* out of memory: keep what we have */
|
|
cur = g_asset_count++;
|
|
cur_obj = i;
|
|
added++;
|
|
g_asset_ids[cur][0] = '\0';
|
|
g_asset_dates[cur][0] = '\0';
|
|
g_asset_is_video[cur] = 0;
|
|
continue;
|
|
}
|
|
if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 ||
|
|
tok[i].parent != cur_obj || i + 1 >= ntok)
|
|
continue;
|
|
|
|
int klen = tok[i].end - tok[i].start;
|
|
const char *k = js + tok[i].start;
|
|
jsmntok_t *val = &tok[i + 1];
|
|
int vlen = val->end - val->start;
|
|
if (val->type != JSMN_STRING)
|
|
continue;
|
|
|
|
if (klen == 2 && !strncmp(k, "id", 2) &&
|
|
vlen > 0 && vlen < (int)sizeof(g_asset_ids[0]))
|
|
snprintf(g_asset_ids[cur], sizeof(g_asset_ids[0]),
|
|
"%.*s", vlen, js + val->start);
|
|
else if (klen == 13 && !strncmp(k, "fileCreatedAt", 13) && vlen >= 10)
|
|
/* keep "YYYY-MM-DDTHH:MM:SS" (or just the date) so the
|
|
* merged grid can be sorted against local files */
|
|
snprintf(g_asset_dates[cur], sizeof(g_asset_dates[0]),
|
|
"%.*s", vlen < DATELEN - 1 ? vlen : DATELEN - 1,
|
|
js + val->start);
|
|
else if (klen == 4 && !strncmp(k, "type", 4))
|
|
g_asset_is_video[cur] =
|
|
(vlen == 5 && !strncmp(js + val->start, "VIDEO", 5));
|
|
}
|
|
free(tok);
|
|
|
|
/* drop trailing entries that somehow lack an id */
|
|
while (g_asset_count > 0 && g_asset_ids[g_asset_count - 1][0] == '\0') {
|
|
g_asset_count--;
|
|
added--;
|
|
}
|
|
return added;
|
|
}
|
|
|
|
/* fetch the next page of the library, newest first; returns assets added */
|
|
static int fetch_page(int is_first)
|
|
{
|
|
if (g_next_page <= 0)
|
|
return 0;
|
|
|
|
char url[600];
|
|
snprintf(url, sizeof(url), "%s/api/search/metadata", g_server);
|
|
char body[128];
|
|
snprintf(body, sizeof(body),
|
|
"{\"page\":%d,\"size\":%d,\"order\":\"desc\"}",
|
|
g_next_page, PAGE_SIZE);
|
|
|
|
membuf buf;
|
|
long code;
|
|
CURLcode res = http_request(url, body, &buf, &code, NULL, 0);
|
|
if (res != CURLE_OK) {
|
|
if (is_first) {
|
|
char det[700];
|
|
snprintf(det, sizeof(det), "%s -- URL: %s",
|
|
curl_easy_strerror(res), url);
|
|
fatal_error(det, "Connection failed (curl error %d)", res);
|
|
}
|
|
log_line("page %d: curl error %d", g_next_page, res);
|
|
g_next_page = 0;
|
|
free(buf.data);
|
|
return 0;
|
|
}
|
|
if (code < 200 || code >= 300) {
|
|
if (is_first)
|
|
fatal_error(buf.data, "Server returned HTTP %ld", code);
|
|
log_line("page %d: HTTP %ld: %.200s", g_next_page, code,
|
|
buf.data ? buf.data : "");
|
|
g_next_page = 0;
|
|
free(buf.data);
|
|
return 0;
|
|
}
|
|
|
|
char err[160];
|
|
int added = parse_assets(buf.data, buf.size, err, sizeof(err));
|
|
if (added < 0) {
|
|
if (is_first)
|
|
fatal_error(buf.data, "Could not parse server response: %s", err);
|
|
log_line("page %d: %s", g_next_page, err);
|
|
g_next_page = 0;
|
|
free(buf.data);
|
|
return 0;
|
|
}
|
|
free(buf.data);
|
|
|
|
/* a short page means we reached the end of the library */
|
|
g_next_page = (added == PAGE_SIZE) ? g_next_page + 1 : 0;
|
|
log_line("page fetched: +%d assets (total %d)", added, g_asset_count);
|
|
return added;
|
|
}
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* background thumbnail loader */
|
|
/* */
|
|
/* One worker thread with a single request slot. The worker downloads */
|
|
/* (reusing one curl handle, so the HTTP connection stays alive) and */
|
|
/* decodes JPEGs to a plain pixel buffer; the main thread turns the */
|
|
/* result into a GXM texture. Handoff is a tiny state machine: */
|
|
/* IDLE -> (main writes request) PENDING -> (worker writes result) */
|
|
/* DONE -> (main consumes) IDLE. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
enum { REQ_IDLE, REQ_PENDING, REQ_DONE };
|
|
|
|
static volatile int g_req_state = REQ_IDLE;
|
|
static volatile int g_req_idx = -1;
|
|
static volatile int g_req_src = SRC_SERVER; /* SRC_SERVER or SRC_LOCAL */
|
|
static volatile int g_req_detail; /* full-res decode for the detail view */
|
|
/* the request payload, copied here by the main thread before REQ_PENDING so
|
|
* the worker never indexes the growable per-asset / per-local arrays (which
|
|
* the main thread may realloc while a request is in flight). */
|
|
static char g_req_id[40]; /* server asset id */
|
|
static char g_req_path[256]; /* local file path */
|
|
static int g_req_is_video;
|
|
static char g_req_err[160]; /* worker -> main error text */
|
|
static unsigned char *g_req_pix; /* decoded pixels, or NULL on failure */
|
|
static int g_req_w, g_req_h, g_req_comps;
|
|
static char *g_req_raw; /* raw body when it's a PNG */
|
|
static size_t g_req_raw_size;
|
|
|
|
/* SceAvPlayer + the hardware video decoder must never run two instances at
|
|
* once. g_player_active is owned by play_video_file (main thread); the worker
|
|
* sets g_poster_active around video-poster extraction. The two sides use a
|
|
* fixed lock order: each sets its own flag, then tests the other's (the worker
|
|
* backs off if the player won; play_video_file only ever waits, bounded). The
|
|
* one that set its flag first is seen by the other, so a mutual wait — main
|
|
* blocked on the worker while the worker is blocked on main — cannot occur. */
|
|
static volatile int g_player_active;
|
|
static volatile int g_poster_active;
|
|
|
|
/* extract the first frame of a local MP4 as a packed RGB888 thumbnail;
|
|
* defined down in the video section, worker-thread safe (no vita2d calls) */
|
|
static unsigned char *extract_video_poster(const char *path, int *w, int *h,
|
|
char *err, size_t errlen);
|
|
|
|
static int worker_thread(SceSize args, void *argp)
|
|
{
|
|
CURL *curl = curl_easy_init();
|
|
char keyhdr[300];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "vitaImmich/0.1 (PS Vita)");
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
|
if (g_resolve_list)
|
|
curl_easy_setopt(curl, CURLOPT_RESOLVE, g_resolve_list);
|
|
|
|
for (;;) {
|
|
if (g_req_state != REQ_PENDING) {
|
|
sceKernelDelayThread(2000);
|
|
continue;
|
|
}
|
|
/* the request is fully described by the g_req_* buffers the main
|
|
* thread filled before REQ_PENDING; never touch the growable
|
|
* arrays here (they may move under us). */
|
|
int src = g_req_src;
|
|
char *err = g_req_err;
|
|
const size_t errlen = sizeof(g_req_err);
|
|
err[0] = '\0';
|
|
const char *label = (src == SRC_LOCAL) ? g_req_path : g_req_id;
|
|
g_req_pix = NULL;
|
|
g_req_raw = NULL;
|
|
|
|
/* local videos have no JPEG to read: decode a poster frame
|
|
* instead. guard against a concurrently playing video — set
|
|
* our flag first, then yield while the player holds the
|
|
* decoder (lock order mirrors play_video_file, see above). */
|
|
if (src == SRC_LOCAL && g_req_is_video) {
|
|
g_poster_active = 1;
|
|
__sync_synchronize();
|
|
while (g_player_active) {
|
|
g_poster_active = 0;
|
|
__sync_synchronize();
|
|
sceKernelDelayThread(50 * 1000);
|
|
g_poster_active = 1;
|
|
__sync_synchronize();
|
|
}
|
|
g_req_pix = extract_video_poster(g_req_path,
|
|
&g_req_w, &g_req_h,
|
|
err, errlen);
|
|
if (g_req_pix)
|
|
g_req_comps = 3;
|
|
g_poster_active = 0;
|
|
__sync_synchronize();
|
|
if (!g_req_pix)
|
|
log_line("thumb %s: %s", label, err);
|
|
__sync_synchronize();
|
|
g_req_state = REQ_DONE;
|
|
continue;
|
|
}
|
|
|
|
membuf buf = { NULL, 0 };
|
|
long code = 0;
|
|
CURLcode res = CURLE_OK;
|
|
|
|
if (src == SRC_LOCAL) {
|
|
/* read the camera JPEG/PNG off the memory card */
|
|
FILE *lf = fopen(g_req_path, "rb");
|
|
if (!lf) {
|
|
snprintf(err, errlen, "open failed");
|
|
} else {
|
|
fseek(lf, 0, SEEK_END);
|
|
long sz = ftell(lf);
|
|
fseek(lf, 0, SEEK_SET);
|
|
if (sz > 0) {
|
|
buf.data = malloc(sz + 1);
|
|
if (buf.data) {
|
|
buf.size = fread(buf.data, 1, sz, lf);
|
|
buf.data[buf.size] = '\0';
|
|
}
|
|
}
|
|
fclose(lf);
|
|
if (!buf.data)
|
|
snprintf(err, errlen, "read failed (%ld bytes)", sz);
|
|
}
|
|
} else {
|
|
char url[700];
|
|
snprintf(url, sizeof(url),
|
|
"%s/api/assets/%s/thumbnail?size=preview",
|
|
g_server, g_req_id);
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
|
|
net_lock(); /* TLS is single-threaded, see g_net_mutex */
|
|
res = curl_easy_perform(curl);
|
|
net_unlock();
|
|
if (res == CURLE_OK)
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
|
|
}
|
|
|
|
if (src == SRC_SERVER && res != CURLE_OK) {
|
|
snprintf(err, errlen, "curl error %d: %s",
|
|
res, curl_easy_strerror(res));
|
|
} else if (src == SRC_SERVER && (code < 200 || code >= 300)) {
|
|
snprintf(err, errlen, "HTTP %ld: %.100s", code,
|
|
buf.data ? buf.data : "(empty body)");
|
|
} else if (buf.size == 0) {
|
|
if (!err[0])
|
|
snprintf(err, errlen, "empty body");
|
|
} else {
|
|
const unsigned char *p = (const unsigned char *)buf.data;
|
|
if (buf.size > 2 && p[0] == 0xff && p[1] == 0xd8) {
|
|
g_req_pix = decode_jpeg_buf(buf.data, buf.size,
|
|
g_req_detail ?
|
|
FULL_MAX :
|
|
THUMB_MAX,
|
|
&g_req_w, &g_req_h,
|
|
&g_req_comps,
|
|
err, errlen);
|
|
} else if (buf.size > 8 && !memcmp(p, "\x89PNG", 4)) {
|
|
/* hand the raw bytes to the main thread,
|
|
* vita2d's PNG loader isn't used off-thread */
|
|
g_req_raw = buf.data;
|
|
g_req_raw_size = buf.size;
|
|
buf.data = NULL;
|
|
} else if (buf.size > 12 && !memcmp(p, "RIFF", 4) &&
|
|
!memcmp(p + 8, "WEBP", 4)) {
|
|
snprintf(err, errlen, "got WebP (unsupported); %u bytes",
|
|
(unsigned)buf.size);
|
|
} else {
|
|
snprintf(err, errlen,
|
|
"unknown format; %u bytes, magic %02x %02x %02x %02x",
|
|
(unsigned)buf.size, p[0], p[1],
|
|
buf.size > 2 ? p[2] : 0,
|
|
buf.size > 3 ? p[3] : 0);
|
|
}
|
|
}
|
|
free(buf.data);
|
|
if (!g_req_pix && !g_req_raw)
|
|
log_line("thumb %s: %s", label, err);
|
|
|
|
__sync_synchronize();
|
|
g_req_state = REQ_DONE;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* build a texture from the worker's decoded result; `pooled` reuses the
|
|
* thumbnail recycle pool, the detail view creates/frees its own */
|
|
static vita2d_texture *req_build_texture(char *err, size_t errlen, int pooled)
|
|
{
|
|
vita2d_texture *tex = NULL;
|
|
|
|
if (g_req_pix) {
|
|
SceGxmTextureFormat fmt = (g_req_comps == 1) ?
|
|
SCE_GXM_TEXTURE_FORMAT_U8_R111 :
|
|
SCE_GXM_TEXTURE_FORMAT_U8U8U8_BGR;
|
|
tex = pooled ? tex_acquire(g_req_w, g_req_h, fmt) :
|
|
vita2d_create_empty_texture_format(g_req_w, g_req_h, fmt);
|
|
if (tex) {
|
|
unsigned char *dst = vita2d_texture_get_datap(tex);
|
|
unsigned int stride = vita2d_texture_get_stride(tex);
|
|
unsigned int rowbytes = g_req_w * g_req_comps;
|
|
for (int y = 0; y < g_req_h; y++)
|
|
memcpy(dst + y * stride,
|
|
g_req_pix + y * rowbytes, rowbytes);
|
|
} else {
|
|
snprintf(err, errlen,
|
|
"texture alloc failed (%dx%d)", g_req_w, g_req_h);
|
|
}
|
|
free(g_req_pix);
|
|
g_req_pix = NULL;
|
|
} else if (g_req_raw) {
|
|
tex = vita2d_load_PNG_buffer(g_req_raw);
|
|
if (!tex)
|
|
snprintf(err, errlen, "PNG decode failed; %u bytes",
|
|
(unsigned)g_req_raw_size);
|
|
free(g_req_raw);
|
|
g_req_raw = NULL;
|
|
}
|
|
return tex;
|
|
}
|
|
|
|
/* main-thread side: turn a finished worker result into a grid thumbnail */
|
|
static void consume_worker_result(void)
|
|
{
|
|
if (g_req_detail) {
|
|
/* a detail-view load whose result nobody wants anymore (the
|
|
* user backed out to the grid mid-load): discard it */
|
|
free(g_req_pix);
|
|
g_req_pix = NULL;
|
|
free(g_req_raw);
|
|
g_req_raw = NULL;
|
|
g_req_state = REQ_IDLE;
|
|
return;
|
|
}
|
|
|
|
int idx = g_req_idx;
|
|
int src = g_req_src;
|
|
char errbuf[64];
|
|
char *err = (src == SRC_LOCAL) ? errbuf : g_tex_err[idx];
|
|
size_t errlen = (src == SRC_LOCAL) ? sizeof(errbuf) :
|
|
sizeof(g_tex_err[0]);
|
|
/* carry the worker's error text across (it wrote into g_req_err so it
|
|
* never touched the growable arrays); main-side failures below overwrite */
|
|
snprintf(err, errlen, "%s", g_req_err);
|
|
vita2d_texture *tex = req_build_texture(err, errlen, 1);
|
|
|
|
if (src == SRC_LOCAL) {
|
|
g_local_thumb[idx] = tex;
|
|
g_local_thumb_failed[idx] = (tex == NULL);
|
|
} else {
|
|
g_thumb[idx] = tex;
|
|
g_thumb_failed[idx] = (tex == NULL);
|
|
}
|
|
g_req_state = REQ_IDLE;
|
|
}
|
|
|
|
/* main-thread side: turn a finished detail-view load into its texture */
|
|
static vita2d_texture *consume_detail_result(void)
|
|
{
|
|
int src = g_req_src;
|
|
char errbuf[160];
|
|
char *err = (src == SRC_LOCAL) ? errbuf : g_tex_err[g_req_idx];
|
|
size_t errlen = (src == SRC_LOCAL) ? sizeof(errbuf) :
|
|
sizeof(g_tex_err[0]);
|
|
snprintf(err, errlen, "%s", g_req_err);
|
|
vita2d_texture *tex = req_build_texture(err, errlen, 0);
|
|
if (!tex)
|
|
log_line("detail %s: %s",
|
|
src == SRC_LOCAL ? g_req_path : g_req_id, err);
|
|
g_req_state = REQ_IDLE;
|
|
return tex;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* texture recycling pool (see g_texpool above) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* release a texture into the pool; never vita2d_free_texture mid-run */
|
|
static void tex_release(vita2d_texture *tex)
|
|
{
|
|
if (!tex)
|
|
return;
|
|
if (g_texpool_n < TEXPOOL_MAX) {
|
|
g_texpool[g_texpool_n++] = tex;
|
|
return;
|
|
}
|
|
/* pool full: freeing is the crash hazard, so prefer leaking the
|
|
* oldest entry's slot over freeing. Shouldn't happen in practice
|
|
* (steady-state live+pooled thumbs stay well under the cap). */
|
|
log_line("texpool full, leaking a texture");
|
|
}
|
|
|
|
/* get a texture of exactly w x h in `fmt`, reusing a pooled one if any */
|
|
static vita2d_texture *tex_acquire(int w, int h, SceGxmTextureFormat fmt)
|
|
{
|
|
for (int i = 0; i < g_texpool_n; i++) {
|
|
vita2d_texture *t = g_texpool[i];
|
|
if ((int)vita2d_texture_get_width(t) == w &&
|
|
(int)vita2d_texture_get_height(t) == h &&
|
|
vita2d_texture_get_format(t) == fmt) {
|
|
g_texpool[i] = g_texpool[--g_texpool_n];
|
|
return t;
|
|
}
|
|
}
|
|
return vita2d_create_empty_texture_format(w, h, fmt);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* merged display model (server + local items) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static const char *disp_date(int d)
|
|
{
|
|
struct disp_item *it = &g_disp[d];
|
|
return it->src == SRC_LOCAL ? g_local_date[it->idx]
|
|
: g_asset_dates[it->idx];
|
|
}
|
|
|
|
static int disp_is_video(int d)
|
|
{
|
|
struct disp_item *it = &g_disp[d];
|
|
return it->src == SRC_LOCAL ? g_local_is_video[it->idx]
|
|
: g_asset_is_video[it->idx];
|
|
}
|
|
|
|
static vita2d_texture *disp_thumb(int d)
|
|
{
|
|
struct disp_item *it = &g_disp[d];
|
|
return it->src == SRC_LOCAL ? g_local_thumb[it->idx]
|
|
: g_thumb[it->idx];
|
|
}
|
|
|
|
static int disp_thumb_failed(int d)
|
|
{
|
|
struct disp_item *it = &g_disp[d];
|
|
return it->src == SRC_LOCAL ? g_local_thumb_failed[it->idx]
|
|
: g_thumb_failed[it->idx];
|
|
}
|
|
|
|
/* request a thumb for anything not yet loaded or failed; local videos get a
|
|
* poster frame extracted on the worker thread, same as photos */
|
|
static int disp_wants_thumb(int d)
|
|
{
|
|
return !disp_thumb(d) && !disp_thumb_failed(d);
|
|
}
|
|
|
|
/* is some server asset id present among the fetched assets? */
|
|
static int server_has_id(const char *id)
|
|
{
|
|
if (!id[0])
|
|
return 0;
|
|
for (int i = 0; i < g_asset_count; i++)
|
|
if (!strcmp(g_asset_ids[i], id))
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
static int disp_cmp(const void *a, const void *b)
|
|
{
|
|
const struct disp_item *x = a, *y = b;
|
|
const char *dx = x->src == SRC_LOCAL ? g_local_date[x->idx]
|
|
: g_asset_dates[x->idx];
|
|
const char *dy = y->src == SRC_LOCAL ? g_local_date[y->idx]
|
|
: g_asset_dates[y->idx];
|
|
return strcmp(dy, dx); /* newest first */
|
|
}
|
|
|
|
/* turn a sortable "YYYY-MM-..." date into a "Month YYYY" section title */
|
|
static void month_label(const char *date, char *out, size_t len)
|
|
{
|
|
static const char *const mon[] = {
|
|
"", "January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"
|
|
};
|
|
int y = 0, m = 0;
|
|
if (strlen(date) >= 7) {
|
|
y = (date[0] - '0') * 1000 + (date[1] - '0') * 100 +
|
|
(date[2] - '0') * 10 + (date[3] - '0');
|
|
m = (date[5] - '0') * 10 + (date[6] - '0');
|
|
}
|
|
if (m < 1 || m > 12)
|
|
snprintf(out, len, "Unknown date");
|
|
else
|
|
snprintf(out, len, "%s %d", mon[m], y);
|
|
}
|
|
|
|
/* compute each display slot's grid cell + the month/year header bands. the
|
|
* timeline is already 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)
|
|
{
|
|
g_sect_count = 0;
|
|
float y = 0.0f;
|
|
int col = 0;
|
|
char curkey[8] = "";
|
|
|
|
for (int d = 0; d < g_disp_count; d++) {
|
|
const char *date = disp_date(d);
|
|
char key[8];
|
|
snprintf(key, sizeof(key), "%.7s", date); /* YYYY-MM */
|
|
|
|
if (strcmp(key, curkey) != 0) {
|
|
if (col != 0) { /* finish the partial last row */
|
|
y += CELL_H;
|
|
col = 0;
|
|
}
|
|
if (grow_sect(g_sect_count + 1)) {
|
|
g_sect[g_sect_count].y = y;
|
|
month_label(date, g_sect[g_sect_count].label,
|
|
sizeof(g_sect[0].label));
|
|
g_sect_count++;
|
|
}
|
|
y += HEADER_H;
|
|
snprintf(curkey, sizeof(curkey), "%s", key);
|
|
}
|
|
|
|
g_item_x[d] = (float)col * CELL_W;
|
|
g_item_y[d] = y;
|
|
if (++col == COLS) {
|
|
col = 0;
|
|
y += CELL_H;
|
|
}
|
|
}
|
|
if (col != 0)
|
|
y += CELL_H;
|
|
g_content_h = y;
|
|
}
|
|
|
|
/* rebuild the merged, date-desc display order. main-thread only (the grid
|
|
* reads g_disp every frame). a backed-up local file that matches a fetched
|
|
* server asset is shown once, as the server asset (green badge). */
|
|
static void rebuild_display(void)
|
|
{
|
|
for (int i = 0; i < g_asset_count; i++)
|
|
g_asset_local_backed[i] = 0;
|
|
|
|
if (!grow_disp(g_asset_count + g_local_count))
|
|
return; /* keep the previous layout if we can't size the new one */
|
|
|
|
int n = 0;
|
|
for (int i = 0; i < g_asset_count; i++) {
|
|
g_disp[n].src = SRC_SERVER;
|
|
g_disp[n].idx = i;
|
|
n++;
|
|
}
|
|
for (int j = 0; j < g_local_count; j++) {
|
|
if (g_local_state[j] == SYNC_BACKED_UP &&
|
|
server_has_id(g_local_server_id[j])) {
|
|
/* fold into the matching server cell */
|
|
for (int i = 0; i < g_asset_count; i++)
|
|
if (!strcmp(g_asset_ids[i], g_local_server_id[j])) {
|
|
g_asset_local_backed[i] = 1;
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
g_disp[n].src = SRC_LOCAL;
|
|
g_disp[n].idx = j;
|
|
n++;
|
|
}
|
|
g_disp_count = n;
|
|
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
|
|
layout_grid();
|
|
}
|
|
|
|
/* first item of the month-run after (dir>0, older) or before (dir<0, newer)
|
|
* the one containing display slot d; the timeline is "YYYY-MM"-grouped and
|
|
* date-descending, so a month is a contiguous run of equal 7-char prefixes */
|
|
static int month_jump(int d, int dir)
|
|
{
|
|
if (g_disp_count == 0)
|
|
return 0;
|
|
if (d < 0)
|
|
d = 0;
|
|
if (d >= g_disp_count)
|
|
d = g_disp_count - 1;
|
|
const char *cur = disp_date(d);
|
|
int i = d;
|
|
if (dir > 0) {
|
|
while (i < g_disp_count - 1) {
|
|
i++;
|
|
if (strncmp(disp_date(i), cur, 7))
|
|
return i; /* first item of the next month */
|
|
}
|
|
return g_disp_count - 1; /* already in the last month */
|
|
}
|
|
/* back to the start of the current month-run */
|
|
while (i > 0 && !strncmp(disp_date(i - 1), cur, 7))
|
|
i--;
|
|
if (i == 0)
|
|
return 0;
|
|
/* then to the start of the previous (newer) month-run */
|
|
const char *prev = disp_date(i - 1);
|
|
i--;
|
|
while (i > 0 && !strncmp(disp_date(i - 1), prev, 7))
|
|
i--;
|
|
return i;
|
|
}
|
|
|
|
/* find the display slot for a given source item (after a rebuild reorders
|
|
* things, to keep the selection on the same photo) */
|
|
static int find_disp(unsigned char src, int idx)
|
|
{
|
|
for (int i = 0; i < g_disp_count; i++)
|
|
if (g_disp[i].src == src && g_disp[i].idx == idx)
|
|
return i;
|
|
return -1;
|
|
}
|
|
|
|
/* pick the most useful thumbnail to load next: selection, then visible,
|
|
* then prefetch a couple of rows below and above the viewport */
|
|
static int pick_next_load(int sel, int first_vis, int last_vis)
|
|
{
|
|
if (sel >= 0 && sel < g_disp_count && disp_wants_thumb(sel))
|
|
return sel;
|
|
for (int i = first_vis; i >= 0 && i <= last_vis && i < g_disp_count; i++)
|
|
if (disp_wants_thumb(i))
|
|
return i;
|
|
for (int i = last_vis + 1; i <= last_vis + COLS && i < g_disp_count; i++)
|
|
if (i >= 0 && disp_wants_thumb(i))
|
|
return i;
|
|
for (int i = first_vis - 1; i >= first_vis - COLS && i >= 0; i--)
|
|
if (disp_wants_thumb(i))
|
|
return i;
|
|
return -1;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* UI */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void draw_texture_fitted(vita2d_texture *tex, float x, float y,
|
|
float box_w, float box_h)
|
|
{
|
|
float w = vita2d_texture_get_width(tex);
|
|
float h = vita2d_texture_get_height(tex);
|
|
float s = (box_w / w < box_h / h) ? box_w / w : box_h / h;
|
|
vita2d_draw_texture_scale(tex, x + (box_w - w * s) / 2.0f,
|
|
y + (box_h - h * s) / 2.0f, s, s);
|
|
}
|
|
|
|
/* the largest pan offset (px) that keeps the scaled image covering the screen
|
|
* edge it's panned away from; 0 when the image is smaller than the screen */
|
|
static float pan_limit(float scaled, float screen)
|
|
{
|
|
float lim = (scaled - screen) / 2.0f;
|
|
return lim > 0.0f ? lim : 0.0f;
|
|
}
|
|
|
|
/* draw the detail image fit-to-screen, then scaled by `zoom` about the centre
|
|
* and shifted by (panx, pany). pan is clamped to keep the image on screen. */
|
|
static void draw_texture_zoom(vita2d_texture *tex, float zoom,
|
|
float *panx, float *pany)
|
|
{
|
|
float w = vita2d_texture_get_width(tex);
|
|
float h = vita2d_texture_get_height(tex);
|
|
float fit = (SCREEN_W / w < SCREEN_H / h) ? SCREEN_W / w : SCREEN_H / h;
|
|
float s = fit * zoom;
|
|
float dw = w * s, dh = h * s;
|
|
|
|
float lx = pan_limit(dw, SCREEN_W), ly = pan_limit(dh, SCREEN_H);
|
|
if (*panx > lx) *panx = lx;
|
|
if (*panx < -lx) *panx = -lx;
|
|
if (*pany > ly) *pany = ly;
|
|
if (*pany < -ly) *pany = -ly;
|
|
|
|
vita2d_draw_texture_scale(tex, (SCREEN_W - dw) / 2.0f + *panx,
|
|
(SCREEN_H - dh) / 2.0f + *pany, s, s);
|
|
}
|
|
|
|
static void draw_sel_outline(float x, float y, float w, float h)
|
|
{
|
|
const uint32_t c = RGBA8(255, 255, 255, 255);
|
|
const float t = 3.0f;
|
|
vita2d_draw_rectangle(x, y, w, t, c);
|
|
vita2d_draw_rectangle(x, y + h - t, w, t, c);
|
|
vita2d_draw_rectangle(x, y, t, h, c);
|
|
vita2d_draw_rectangle(x + w - t, y, t, h, c);
|
|
}
|
|
|
|
static void draw_hud(const char *text)
|
|
{
|
|
vita2d_draw_rectangle(0, SCREEN_H - 32, SCREEN_W, 32, RGBA8(0, 0, 0, 180));
|
|
vita2d_pgf_draw_text(g_font, 10, SCREEN_H - 9,
|
|
RGBA8(255, 255, 255, 255), 1.0f, text);
|
|
}
|
|
|
|
static void draw_error_detail(int idx)
|
|
{
|
|
draw_centered(220, RGBA8(255, 80, 80, 255), "Failed to load this photo");
|
|
vita2d_pgf_draw_text(g_font, 40, 270, RGBA8(200, 200, 200, 255),
|
|
1.0f, g_asset_ids[idx]);
|
|
const char *e = g_tex_err[idx];
|
|
int len = strlen(e), y = 300;
|
|
const int per_line = 76;
|
|
for (int off = 0; off < len && y < 500; off += per_line, y += 26) {
|
|
char line[80];
|
|
snprintf(line, sizeof(line), "%.*s", per_line, e + off);
|
|
vita2d_pgf_draw_text(g_font, 40, y, RGBA8(200, 200, 200, 255), 1.0f, line);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* video download + playback */
|
|
/* */
|
|
/* The asset's transcoded playback stream is downloaded to the memory */
|
|
/* card, then played with SceAvPlayer (the OS's hardware MP4/H.264 */
|
|
/* decoder). Frame buffers are CDRAM memblocks mapped into GXM, so */
|
|
/* decoded NV12 frames are drawn zero-copy by pointing a YUV texture */
|
|
/* at them. Audio runs on its own thread; sceAvPlayerGetVideoData */
|
|
/* paces video against it internally. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define ALIGN_UP(x, a) (((x) + ((a) - 1)) & ~((a) - 1))
|
|
|
|
/* draw an error screen until O is pressed */
|
|
static void show_blocking_error(const char *title, const char *detail)
|
|
{
|
|
SceCtrlData pad;
|
|
do {
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
draw_centered(230, RGBA8(255, 80, 80, 255), title);
|
|
if (detail && detail[0])
|
|
draw_centered(280, RGBA8(200, 200, 200, 255), detail);
|
|
draw_centered(510, RGBA8(160, 160, 160, 255), "O back");
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
} while (!(pad.buttons & SCE_CTRL_CIRCLE));
|
|
}
|
|
|
|
static size_t file_write_cb(void *ptr, size_t size, size_t nmemb, void *ud)
|
|
{
|
|
return fwrite(ptr, size, nmemb, (FILE *)ud);
|
|
}
|
|
|
|
static uint64_t g_dl_last_draw;
|
|
|
|
static int dl_progress_cb(void *ud, curl_off_t dltotal, curl_off_t dlnow,
|
|
curl_off_t ultotal, curl_off_t ulnow)
|
|
{
|
|
SceCtrlData pad;
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
if (pad.buttons & SCE_CTRL_CIRCLE)
|
|
return 1; /* abort the transfer */
|
|
|
|
/* curl calls this very often; only redraw every 100 ms */
|
|
uint64_t now = sceKernelGetProcessTimeWide();
|
|
if (now - g_dl_last_draw < 100 * 1000)
|
|
return 0;
|
|
g_dl_last_draw = now;
|
|
|
|
char buf[128];
|
|
if (dltotal > 0)
|
|
snprintf(buf, sizeof(buf), "Downloading video... %.1f / %.1f MB",
|
|
dlnow / (1024.0 * 1024.0), dltotal / (1024.0 * 1024.0));
|
|
else
|
|
snprintf(buf, sizeof(buf), "Downloading video... %.1f MB",
|
|
dlnow / (1024.0 * 1024.0));
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
draw_centered(SCREEN_H / 2 - 20, RGBA8(255, 255, 255, 255), buf);
|
|
if (dltotal > 0) {
|
|
float frac = (float)dlnow / (float)dltotal;
|
|
vita2d_draw_rectangle(180, SCREEN_H / 2 + 10, 600, 14,
|
|
RGBA8(60, 60, 60, 255));
|
|
vita2d_draw_rectangle(180, SCREEN_H / 2 + 10, 600.0f * frac, 14,
|
|
RGBA8(120, 200, 120, 255));
|
|
}
|
|
draw_centered(SCREEN_H / 2 + 60, RGBA8(160, 160, 160, 255), "O cancel");
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
return 0;
|
|
}
|
|
|
|
/* download the playback stream of asset idx to VIDEO_TMP_PATH.
|
|
* returns 0 on success, 1 if the user cancelled, -1 on error. */
|
|
static int download_video(int idx, char *err, size_t errlen)
|
|
{
|
|
char url[700];
|
|
snprintf(url, sizeof(url), "%s/api/assets/%s/video/playback",
|
|
g_server, g_asset_ids[idx]);
|
|
|
|
FILE *f = fopen(VIDEO_TMP_PATH, "wb");
|
|
if (!f) {
|
|
snprintf(err, errlen, "cannot create %s", VIDEO_TMP_PATH);
|
|
return -1;
|
|
}
|
|
|
|
CURL *curl = curl_easy_init();
|
|
if (!curl) {
|
|
fclose(f);
|
|
snprintf(err, errlen, "curl init failed");
|
|
return -1;
|
|
}
|
|
|
|
char keyhdr[300];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr);
|
|
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, f);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
/* no overall timeout (videos can be big); abort on a 30 s stall */
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, dl_progress_cb);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "vitaImmich/0.1 (PS Vita)");
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
|
if (g_resolve_list)
|
|
curl_easy_setopt(curl, CURLOPT_RESOLVE, g_resolve_list);
|
|
|
|
g_dl_last_draw = 0;
|
|
net_lock(); /* TLS is single-threaded, see g_net_mutex */
|
|
CURLcode res = curl_easy_perform(curl);
|
|
net_unlock();
|
|
long code = 0;
|
|
if (res == CURLE_OK)
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
|
|
curl_slist_free_all(hdrs);
|
|
curl_easy_cleanup(curl);
|
|
fclose(f);
|
|
|
|
if (res == CURLE_ABORTED_BY_CALLBACK) {
|
|
sceIoRemove(VIDEO_TMP_PATH);
|
|
return 1;
|
|
}
|
|
if (res != CURLE_OK) {
|
|
snprintf(err, errlen, "curl error %d: %s", res,
|
|
curl_easy_strerror(res));
|
|
sceIoRemove(VIDEO_TMP_PATH);
|
|
return -1;
|
|
}
|
|
if (code < 200 || code >= 300) {
|
|
snprintf(err, errlen, "HTTP %ld", code);
|
|
sceIoRemove(VIDEO_TMP_PATH);
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void *av_alloc(void *p, uint32_t alignment, uint32_t size)
|
|
{
|
|
return memalign(alignment, size);
|
|
}
|
|
|
|
static void av_free(void *p, void *ptr)
|
|
{
|
|
free(ptr);
|
|
}
|
|
|
|
/* video frame buffers must be GPU-visible: CDRAM memblocks mapped into GXM */
|
|
static void *av_gpu_alloc(void *p, uint32_t alignment, uint32_t size)
|
|
{
|
|
if (alignment < 0x40000)
|
|
alignment = 0x40000; /* CDRAM memblocks are 256 KiB granular */
|
|
size = ALIGN_UP(size, alignment);
|
|
|
|
SceKernelAllocMemBlockOpt opt;
|
|
memset(&opt, 0, sizeof(opt));
|
|
opt.size = sizeof(opt);
|
|
opt.attr = SCE_KERNEL_ALLOC_MEMBLOCK_ATTR_HAS_ALIGNMENT;
|
|
opt.alignment = alignment;
|
|
SceUID mb = sceKernelAllocMemBlock("vimm_vframe",
|
|
SCE_KERNEL_MEMBLOCK_TYPE_USER_CDRAM_RW,
|
|
size, &opt);
|
|
if (mb < 0) {
|
|
log_line("video: frame alloc failed (%u bytes): 0x%08x", size, mb);
|
|
return NULL;
|
|
}
|
|
void *base = NULL;
|
|
sceKernelGetMemBlockBase(mb, &base);
|
|
sceGxmMapMemory(base, size,
|
|
SCE_GXM_MEMORY_ATTRIB_READ | SCE_GXM_MEMORY_ATTRIB_WRITE);
|
|
return base;
|
|
}
|
|
|
|
static void av_gpu_free(void *p, void *ptr)
|
|
{
|
|
SceUID mb = sceKernelFindMemBlockByAddr(ptr, 0);
|
|
sceGxmUnmapMemory(ptr);
|
|
if (mb >= 0)
|
|
sceKernelFreeMemBlock(mb);
|
|
}
|
|
|
|
/* load the SceAvPlayer module once. shared by playback and poster extraction
|
|
* (either thread can be the first to need it); returns 0 once loaded, or a
|
|
* negative module error. a second load returns ALREADY_LOADED, so the latch
|
|
* keeps that from being mistaken for a failure. */
|
|
static volatile int g_avplayer_loaded;
|
|
|
|
static int load_avplayer_module(void)
|
|
{
|
|
if (g_avplayer_loaded)
|
|
return 0;
|
|
int mret = sceSysmoduleLoadModule(SCE_SYSMODULE_AVPLAYER);
|
|
log_line("video: load AVPLAYER module: 0x%08x", mret);
|
|
if (mret < 0)
|
|
return mret;
|
|
g_avplayer_loaded = 1;
|
|
return 0;
|
|
}
|
|
|
|
/* Extract the first video frame of an MP4 as a tightly-packed RGB888 buffer,
|
|
* nearest-neighbour downscaled so the long side is <= THUMB_MAX. Runs on the
|
|
* WORKER thread: no vita2d/GXM drawing, only SceAvPlayer + the av_gpu_alloc
|
|
* memblock helper (which the architecture permits off the main thread).
|
|
* Returns a malloc'd buffer (caller frees) with w/h set, or NULL on failure
|
|
* (err set, and the failing avplayer step logged). */
|
|
static unsigned char *extract_video_poster(const char *path, int *w, int *h,
|
|
char *err, size_t errlen)
|
|
{
|
|
if (load_avplayer_module() < 0) {
|
|
snprintf(err, errlen, "avplayer module load failed");
|
|
log_line("poster %s: module load failed", path);
|
|
return NULL;
|
|
}
|
|
|
|
SceAvPlayerInitData init;
|
|
memset(&init, 0, sizeof(init));
|
|
init.memoryReplacement.allocate = av_alloc;
|
|
init.memoryReplacement.deallocate = av_free;
|
|
init.memoryReplacement.allocateTexture = av_gpu_alloc;
|
|
init.memoryReplacement.deallocateTexture = av_gpu_free;
|
|
init.basePriority = 0xA0;
|
|
init.numOutputVideoFrameBuffers = 2;
|
|
init.autoStart = 1;
|
|
init.defaultLanguage = "eng";
|
|
|
|
/* like play_video_file: the handle is a context pointer, init failure
|
|
* is 0/NULL rather than a negative error */
|
|
SceAvPlayerHandle avp = sceAvPlayerInit(&init);
|
|
if (avp == 0) {
|
|
snprintf(err, errlen, "sceAvPlayerInit returned NULL");
|
|
log_line("poster %s: sceAvPlayerInit returned NULL", path);
|
|
return NULL;
|
|
}
|
|
int ret = sceAvPlayerAddSource(avp, path);
|
|
if (ret < 0) {
|
|
snprintf(err, errlen, "sceAvPlayerAddSource 0x%08x", ret);
|
|
log_line("poster %s: sceAvPlayerAddSource 0x%08x", path, ret);
|
|
sceAvPlayerClose(avp);
|
|
return NULL;
|
|
}
|
|
|
|
/* the source parses asynchronously; wait ~5 s for it to become active */
|
|
int active = 0;
|
|
for (int i = 0; i < 500 && !(active = sceAvPlayerIsActive(avp)); i++)
|
|
sceKernelDelayThread(10 * 1000);
|
|
if (!active) {
|
|
snprintf(err, errlen, "never became active (codec?)");
|
|
log_line("poster %s: never became active", path);
|
|
sceAvPlayerStop(avp);
|
|
sceAvPlayerClose(avp);
|
|
return NULL;
|
|
}
|
|
|
|
/* poll ~5 s for the first decoded frame */
|
|
SceAvPlayerFrameInfo frame;
|
|
int got = 0;
|
|
for (int i = 0; i < 500; i++) {
|
|
memset(&frame, 0, sizeof(frame));
|
|
if (sceAvPlayerGetVideoData(avp, &frame)) {
|
|
got = 1;
|
|
break;
|
|
}
|
|
if (!sceAvPlayerIsActive(avp))
|
|
break;
|
|
sceKernelDelayThread(10 * 1000);
|
|
}
|
|
if (!got) {
|
|
snprintf(err, errlen, "no video frame");
|
|
log_line("poster %s: sceAvPlayerGetVideoData timed out", path);
|
|
sceAvPlayerStop(avp);
|
|
sceAvPlayerClose(avp);
|
|
return NULL;
|
|
}
|
|
|
|
int sw = (int)frame.details.video.width;
|
|
int sh = (int)frame.details.video.height;
|
|
if (sw <= 0 || sh <= 0) {
|
|
snprintf(err, errlen, "bad frame size %dx%d", sw, sh);
|
|
log_line("poster %s: bad frame size %dx%d", path, sw, sh);
|
|
sceAvPlayerStop(avp);
|
|
sceAvPlayerClose(avp);
|
|
return NULL;
|
|
}
|
|
|
|
/* integer downscale step so the long side lands <= THUMB_MAX */
|
|
int step = 1;
|
|
while ((sw > sh ? sw : sh) / step > THUMB_MAX)
|
|
step++;
|
|
int dw = sw / step, dh = sh / step;
|
|
if (dw < 1) dw = 1;
|
|
if (dh < 1) dh = 1;
|
|
|
|
unsigned char *rgb = malloc((size_t)dw * dh * 3);
|
|
if (!rgb) {
|
|
snprintf(err, errlen, "out of memory (%dx%d)", dw, dh);
|
|
log_line("poster %s: out of memory (%dx%d)", path, dw, dh);
|
|
sceAvPlayerStop(avp);
|
|
sceAvPlayerClose(avp);
|
|
return NULL;
|
|
}
|
|
|
|
/* NV12: Y plane (sw*sh bytes), then interleaved U,V at half resolution
|
|
* (row pitch sw, one U,V pair per 2x2 luma block). nearest-neighbour
|
|
* sample + integer BT.601 YUV->RGB. byte order matches decode_jpeg_buf
|
|
* (R,G,B), which the U8U8U8_BGR texture in consume_worker_result wants. */
|
|
const unsigned char *yp = (const unsigned char *)frame.pData;
|
|
const unsigned char *uvp = yp + (size_t)sw * sh;
|
|
unsigned char *o = rgb;
|
|
for (int dy = 0; dy < dh; dy++) {
|
|
int sy = dy * step;
|
|
const unsigned char *yrow = yp + (size_t)sy * sw;
|
|
const unsigned char *uvrow = uvp + (size_t)(sy / 2) * sw;
|
|
for (int dx = 0; dx < dw; dx++) {
|
|
int sx = dx * step;
|
|
int Y = yrow[sx];
|
|
int U = uvrow[(sx & ~1)];
|
|
int V = uvrow[(sx & ~1) + 1];
|
|
int C = Y - 16, D = U - 128, E = V - 128;
|
|
int R = (298 * C + 409 * E + 128) >> 8;
|
|
int G = (298 * C - 100 * D - 208 * E + 128) >> 8;
|
|
int B = (298 * C + 516 * D + 128) >> 8;
|
|
*o++ = R < 0 ? 0 : R > 255 ? 255 : R;
|
|
*o++ = G < 0 ? 0 : G > 255 ? 255 : G;
|
|
*o++ = B < 0 ? 0 : B > 255 ? 255 : B;
|
|
}
|
|
}
|
|
|
|
*w = dw;
|
|
*h = dh;
|
|
sceAvPlayerStop(avp);
|
|
sceAvPlayerClose(avp);
|
|
return rgb;
|
|
}
|
|
|
|
static SceAvPlayerHandle g_avp;
|
|
static volatile int g_av_audio_run;
|
|
|
|
/* sceAudioOutOutput blocks until the previous chunk drains, so this
|
|
* thread is naturally paced by the audio hardware */
|
|
static int video_audio_thread(SceSize args, void *argp)
|
|
{
|
|
int port = sceAudioOutOpenPort(SCE_AUDIO_OUT_PORT_TYPE_BGM, 1024, 48000,
|
|
SCE_AUDIO_OUT_MODE_STEREO);
|
|
if (port < 0) {
|
|
log_line("video: audio port open failed: 0x%08x", port);
|
|
return 0;
|
|
}
|
|
int vol[2] = { 32767, 32767 };
|
|
sceAudioOutSetVolume(port, SCE_AUDIO_VOLUME_FLAG_L_CH |
|
|
SCE_AUDIO_VOLUME_FLAG_R_CH, vol);
|
|
|
|
SceAvPlayerFrameInfo frame;
|
|
memset(&frame, 0, sizeof(frame));
|
|
while (g_av_audio_run) {
|
|
if (sceAvPlayerIsActive(g_avp) &&
|
|
sceAvPlayerGetAudioData(g_avp, &frame)) {
|
|
sceAudioOutSetConfig(port, -1,
|
|
frame.details.audio.sampleRate,
|
|
frame.details.audio.channelCount == 1 ?
|
|
SCE_AUDIO_OUT_MODE_MONO :
|
|
SCE_AUDIO_OUT_MODE_STEREO);
|
|
sceAudioOutOutput(port, frame.pData);
|
|
} else {
|
|
sceKernelDelayThread(1000);
|
|
}
|
|
}
|
|
sceAudioOutReleasePort(port);
|
|
return 0;
|
|
}
|
|
|
|
/* play a local MP4 full screen; returns when it ends or O is pressed */
|
|
static void play_video_file(const char *path)
|
|
{
|
|
if (load_avplayer_module() < 0) {
|
|
show_blocking_error("Could not load the video player module",
|
|
"sceSysmoduleLoadModule failed");
|
|
return;
|
|
}
|
|
|
|
/* claim the hardware decoder: announce ourselves, then wait (bounded,
|
|
* ~12 s — longer than extraction's worst-case timeouts) for any
|
|
* in-flight worker poster extraction to finish. lock-order mirror of
|
|
* the worker guard — we set our flag before waiting, so the worker
|
|
* sees it and backs off; we only ever wait here, never hold a lock
|
|
* the worker is also waiting on, so the two cannot deadlock. */
|
|
g_player_active = 1;
|
|
__sync_synchronize();
|
|
for (int i = 0; i < 720 && g_poster_active; i++)
|
|
show_status("Starting video...");
|
|
|
|
/* replay re-inits the player from the same (already-local) file, so the
|
|
* decoder claim above is taken once and held across replays. */
|
|
int want_replay;
|
|
replay:
|
|
want_replay = 0;
|
|
|
|
SceAvPlayerInitData init;
|
|
memset(&init, 0, sizeof(init));
|
|
init.memoryReplacement.allocate = av_alloc;
|
|
init.memoryReplacement.deallocate = av_free;
|
|
init.memoryReplacement.allocateTexture = av_gpu_alloc;
|
|
init.memoryReplacement.deallocateTexture = av_gpu_free;
|
|
init.basePriority = 0xA0;
|
|
/* four rotating frame buffers (not two): GPU rendering is async, so two
|
|
* let the decoder recycle a buffer the GPU is still sampling, which
|
|
* tears/glitches. 1080p NV12 is ~3.2 MB/buffer, so 4 ~= 13 MB CDRAM. */
|
|
init.numOutputVideoFrameBuffers = 4;
|
|
init.autoStart = 1;
|
|
init.defaultLanguage = "eng";
|
|
|
|
/* the handle is a pointer to the player context (so often has bit 31
|
|
* set); init failure returns 0, not a negative error code */
|
|
g_avp = sceAvPlayerInit(&init);
|
|
if (g_avp == 0) {
|
|
log_line("video: sceAvPlayerInit failed");
|
|
show_blocking_error("Could not start the video player",
|
|
"sceAvPlayerInit returned NULL");
|
|
g_player_active = 0;
|
|
__sync_synchronize();
|
|
return;
|
|
}
|
|
log_line("video: player handle 0x%08x", g_avp);
|
|
int ret = sceAvPlayerAddSource(g_avp, path);
|
|
if (ret < 0) {
|
|
char det[64];
|
|
snprintf(det, sizeof(det), "sceAvPlayerAddSource: 0x%08x", ret);
|
|
log_line("video: %s", det);
|
|
sceAvPlayerClose(g_avp);
|
|
show_blocking_error("Could not open the video file", det);
|
|
g_player_active = 0;
|
|
__sync_synchronize();
|
|
return;
|
|
}
|
|
|
|
g_av_audio_run = 1;
|
|
SceUID audio_thid = sceKernelCreateThread("video_audio",
|
|
video_audio_thread,
|
|
0x10000100, 64 * 1024,
|
|
0, 0, NULL);
|
|
if (audio_thid >= 0)
|
|
sceKernelStartThread(audio_thid, 0, NULL);
|
|
|
|
/* AddSource parses asynchronously; give it ~5 s to start */
|
|
int active = 0;
|
|
for (int i = 0; i < 300 && !(active = sceAvPlayerIsActive(g_avp)); i++)
|
|
show_status("Starting video...");
|
|
|
|
/* AddSource parses asynchronously, so the stream duration is usually not
|
|
* available yet at this point; keep re-querying it in the loop below until
|
|
* it becomes known instead of latching the initial 0. */
|
|
uint64_t duration = 0;
|
|
|
|
/* four rotating frame wrappers (matching numOutputVideoFrameBuffers):
|
|
* while the GPU still samples one frame the decoder fills another, and
|
|
* the deeper ring keeps it from reusing a buffer that is still in flight.
|
|
* the wrappers never own memory (the decoder's CDRAM buffers do), so we
|
|
* must never vita2d_free_texture them. */
|
|
vita2d_texture vtex[4];
|
|
SceAvPlayerFrameInfo vframe[4];
|
|
memset(vtex, 0, sizeof(vtex));
|
|
memset(vframe, 0, sizeof(vframe));
|
|
int buf_idx = 0;
|
|
vita2d_texture *cur = NULL;
|
|
|
|
int paused = 0;
|
|
int ended_eos = 0;
|
|
unsigned int prev = 0xffffffff; /* swallow the X press that got us here */
|
|
|
|
while (active) {
|
|
SceCtrlData pad;
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
unsigned int pressed = pad.buttons & ~prev;
|
|
prev = pad.buttons;
|
|
|
|
if (pressed & SCE_CTRL_CIRCLE)
|
|
break;
|
|
if (pressed & SCE_CTRL_CROSS) {
|
|
if (paused)
|
|
sceAvPlayerResume(g_avp);
|
|
else
|
|
sceAvPlayerPause(g_avp);
|
|
paused = !paused;
|
|
}
|
|
if (!paused && (pressed & (SCE_CTRL_LEFT | SCE_CTRL_RIGHT))) {
|
|
uint64_t t = sceAvPlayerCurrentTime(g_avp);
|
|
if (pressed & SCE_CTRL_RIGHT)
|
|
t += 10000;
|
|
else
|
|
t = t > 10000 ? t - 10000 : 0;
|
|
if (duration == 0 || t < duration)
|
|
sceAvPlayerJumpToTime(g_avp, t);
|
|
}
|
|
|
|
if (!sceAvPlayerIsActive(g_avp)) {
|
|
ended_eos = 1;
|
|
break; /* end of stream */
|
|
}
|
|
|
|
if (sceAvPlayerGetVideoData(g_avp, &vframe[buf_idx])) {
|
|
sceGxmTextureInitLinear(&vtex[buf_idx].gxm_tex,
|
|
vframe[buf_idx].pData,
|
|
SCE_GXM_TEXTURE_FORMAT_YVU420P2_CSC1,
|
|
vframe[buf_idx].details.video.width,
|
|
vframe[buf_idx].details.video.height,
|
|
0);
|
|
cur = &vtex[buf_idx];
|
|
buf_idx = (buf_idx + 1) & 3;
|
|
}
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
if (cur)
|
|
draw_texture_fitted(cur, 0, 0, SCREEN_W, SCREEN_H);
|
|
|
|
if (duration == 0) {
|
|
SceAvPlayerStreamInfo sinfo;
|
|
memset(&sinfo, 0, sizeof(sinfo));
|
|
if (sceAvPlayerGetStreamInfo(g_avp, 0, &sinfo) >= 0)
|
|
duration = sinfo.duration;
|
|
}
|
|
|
|
unsigned int cs = (unsigned int)(sceAvPlayerCurrentTime(g_avp) / 1000);
|
|
unsigned int ds = (unsigned int)(duration / 1000);
|
|
char hud[160];
|
|
snprintf(hud, sizeof(hud),
|
|
"%u:%02u / %u:%02u%s < > seek 10s X %s O back",
|
|
cs / 60, cs % 60, ds / 60, ds % 60,
|
|
paused ? " [paused]" : "",
|
|
paused ? "resume" : "pause");
|
|
draw_hud(hud);
|
|
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
}
|
|
|
|
if (!active) {
|
|
log_line("video: player never became active (unsupported codec?)");
|
|
show_blocking_error("Could not play this video",
|
|
"The Vita plays MP4 (H.264/AAC) only; "
|
|
"check Immich transcoding settings.");
|
|
}
|
|
|
|
/* reached the end of the video: hold on the last frame with a replay
|
|
* prompt instead of returning (which, for a server video, drops the user
|
|
* back to the detail screen and would re-download to play it again). X
|
|
* replays from the still-local file; O leaves. The decoder buffers behind
|
|
* `cur` are still valid here — teardown happens below, after this loop. */
|
|
if (ended_eos) {
|
|
unsigned int eprev = 0xffffffff;
|
|
for (;;) {
|
|
SceCtrlData pad;
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
unsigned int pressed = pad.buttons & ~eprev;
|
|
eprev = pad.buttons;
|
|
|
|
if (pressed & SCE_CTRL_CIRCLE)
|
|
break;
|
|
if (pressed & SCE_CTRL_CROSS) {
|
|
want_replay = 1;
|
|
break;
|
|
}
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
if (cur)
|
|
draw_texture_fitted(cur, 0, 0, SCREEN_W, SCREEN_H);
|
|
draw_hud("ended X replay O back");
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
}
|
|
}
|
|
|
|
g_av_audio_run = 0;
|
|
if (audio_thid >= 0) {
|
|
sceKernelWaitThreadEnd(audio_thid, NULL, NULL);
|
|
sceKernelDeleteThread(audio_thid);
|
|
}
|
|
/* Close() frees the frame the GPU may still be sampling */
|
|
vita2d_wait_rendering_done();
|
|
sceAvPlayerStop(g_avp);
|
|
sceAvPlayerClose(g_avp);
|
|
|
|
if (want_replay)
|
|
goto replay; /* re-init from the same file; no re-download */
|
|
|
|
/* release the decoder so the worker can resume poster extraction */
|
|
g_player_active = 0;
|
|
__sync_synchronize();
|
|
}
|
|
|
|
/* download + play asset idx, cleaning up the temp file afterwards */
|
|
static void view_video(int idx)
|
|
{
|
|
char err[160];
|
|
int r = download_video(idx, err, sizeof(err));
|
|
if (r == 0) {
|
|
log_line("video %s: downloaded, playing", g_asset_ids[idx]);
|
|
play_video_file(VIDEO_TMP_PATH);
|
|
} else if (r < 0) {
|
|
log_line("video %s: %s", g_asset_ids[idx], err);
|
|
show_blocking_error("Failed to download video", err);
|
|
}
|
|
sceIoRemove(VIDEO_TMP_PATH);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* local camera media: scan, hash, check, upload */
|
|
/* */
|
|
/* The scan (paths + stat only) runs on the main thread at startup; the */
|
|
/* slow work — SHA1 hashing, the bulk dup-check and uploads — runs on a */
|
|
/* second background thread so the UI never blocks. Item state lives in */
|
|
/* the volatile g_local_state[] machine; the sync thread is the only */
|
|
/* writer except for the QUEUED handoff (main thread, guarded). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* build "YYYY-MM-DDTHH:MM:SS" (sortable) from a SceDateTime. fields are
|
|
* masked to their printed width so the output can never overrun out[] */
|
|
static void datetime_sortable(const SceDateTime *t, char *out, size_t len)
|
|
{
|
|
snprintf(out, len, "%04d-%02d-%02dT%02d:%02d:%02d",
|
|
t->year % 10000, t->month % 100, t->day % 100,
|
|
t->hour % 100, t->minute % 100, t->second % 100);
|
|
}
|
|
|
|
/* build ISO8601 with milliseconds + Z, as Immich's API expects */
|
|
static void datetime_iso(const SceDateTime *t, char *out, size_t len)
|
|
{
|
|
snprintf(out, len, "%04d-%02d-%02dT%02d:%02d:%02d.000Z",
|
|
t->year % 10000, t->month % 100, t->day % 100,
|
|
t->hour % 100, t->minute % 100, t->second % 100);
|
|
}
|
|
|
|
static int ext_is(const char *name, const char *ext)
|
|
{
|
|
size_t n = strlen(name), e = strlen(ext);
|
|
return n > e && !strcasecmp(name + n - e, ext);
|
|
}
|
|
|
|
static int is_media_name(const char *name, int *is_video)
|
|
{
|
|
if (ext_is(name, ".jpg") || ext_is(name, ".jpeg") || ext_is(name, ".png")) {
|
|
*is_video = 0;
|
|
return 1;
|
|
}
|
|
if (ext_is(name, ".mp4")) {
|
|
*is_video = 1;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* recurse a directory (up to depth levels) collecting camera media */
|
|
static void scan_dir(const char *path, int depth)
|
|
{
|
|
SceUID dfd = sceIoDopen(path);
|
|
if (dfd < 0) {
|
|
/* a safe (sandboxed) self gets an error here; the vpk must be
|
|
* built UNSAFE and unsafe homebrew enabled in HENkaku settings */
|
|
log_line("scan: cannot open %s: 0x%08x", path, dfd);
|
|
return;
|
|
}
|
|
|
|
SceIoDirent ent;
|
|
memset(&ent, 0, sizeof(ent));
|
|
while (sceIoDread(dfd, &ent) > 0) {
|
|
if (ent.d_name[0] == '.')
|
|
continue;
|
|
|
|
/* build "path/name" by hand (bounded, no snprintf %s
|
|
* truncation warning); skip anything too long to store */
|
|
size_t pl = strlen(path), nl = strlen(ent.d_name);
|
|
if (pl + 1 + nl + 1 > sizeof(g_local_path[0]))
|
|
continue;
|
|
char full[sizeof(g_local_path[0])];
|
|
memcpy(full, path, pl);
|
|
full[pl] = '/';
|
|
memcpy(full + pl + 1, ent.d_name, nl + 1);
|
|
|
|
/* some IO drivers report directory-ness only in st_attr */
|
|
if (SCE_S_ISDIR(ent.d_stat.st_mode) ||
|
|
SCE_SO_ISDIR(ent.d_stat.st_attr)) {
|
|
if (depth > 0)
|
|
scan_dir(full, depth - 1);
|
|
continue;
|
|
}
|
|
|
|
int is_video;
|
|
if (!is_media_name(ent.d_name, &is_video))
|
|
continue;
|
|
if (!strcmp(full, VIDEO_TMP_PATH)) /* our own temp file */
|
|
continue;
|
|
if (g_syncmax_mb > 0 &&
|
|
(long long)ent.d_stat.st_size > g_syncmax_mb * 1024LL * 1024LL) {
|
|
log_line("scan: skip %s (%lld MB > syncmaxmb=%ld)", full,
|
|
(long long)ent.d_stat.st_size >> 20, g_syncmax_mb);
|
|
continue;
|
|
}
|
|
|
|
if (!grow_locals(g_local_count + 1)) {
|
|
log_line("scan: out of memory at %d local files",
|
|
g_local_count);
|
|
break;
|
|
}
|
|
int j = g_local_count++;
|
|
memcpy(g_local_path[j], full, pl + 1 + nl + 1);
|
|
g_local_is_video[j] = is_video;
|
|
g_local_size[j] = (long long)ent.d_stat.st_size;
|
|
g_local_mtime[j] = ent.d_stat.st_mtime;
|
|
g_local_ctime[j] = ent.d_stat.st_ctime;
|
|
datetime_sortable(&ent.d_stat.st_mtime, g_local_date[j],
|
|
sizeof(g_local_date[j]));
|
|
g_local_sha1[j][0] = '\0';
|
|
g_local_server_id[j][0] = '\0';
|
|
g_local_err[j][0] = '\0';
|
|
g_local_state[j] = SYNC_UNSCANNED;
|
|
}
|
|
sceIoDclose(dfd);
|
|
log_line("scan: %s: %d media so far", path, g_local_count);
|
|
}
|
|
|
|
/* scan the Vita's camera folders (main thread, fast: paths + stat only) */
|
|
static int g_photo0_mounted;
|
|
|
|
static void scan_local_media(void)
|
|
{
|
|
g_local_count = 0;
|
|
if (g_syncdir_count > 0) {
|
|
for (int i = 0; i < g_syncdir_count; i++)
|
|
scan_dir(g_syncdirs[i], 3);
|
|
} else {
|
|
/* ux0:picture is ACL-protected (EPERM) even for unsafe
|
|
* homebrew; the photo0: appdata mount is the sanctioned way
|
|
* in. Camera videos live there too (the Photos app owns
|
|
* them), so all of ux0:video — the movie collection — is
|
|
* left alone. */
|
|
int mres = sceAppMgrAppDataMount(100, "photo0:");
|
|
g_photo0_mounted = (mres == 0);
|
|
log_line("scan: photo0 mount: 0x%08x", mres);
|
|
scan_dir(g_photo0_mounted ? "photo0:" : "ux0:picture", 3);
|
|
scan_dir("ux0:video/CAMERA", 3);
|
|
}
|
|
log_line("local scan: %d files", g_local_count);
|
|
}
|
|
|
|
static int sha1_file_hex(const char *path, char out[41])
|
|
{
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f)
|
|
return -1;
|
|
SHA_CTX c;
|
|
SHA1_Init(&c);
|
|
static unsigned char buf[64 * 1024]; /* big, keep off the stack */
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
|
|
SHA1_Update(&c, buf, n);
|
|
int err = ferror(f);
|
|
fclose(f);
|
|
if (err)
|
|
return -1;
|
|
unsigned char d[20];
|
|
SHA1_Final(d, &c);
|
|
for (int i = 0; i < 20; i++)
|
|
snprintf(out + 2 * i, 3, "%02x", d[i]);
|
|
out[40] = '\0';
|
|
return 0;
|
|
}
|
|
|
|
static void sync_push_err(const char *fmt, ...)
|
|
{
|
|
char buf[160];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(buf, sizeof(buf), fmt, ap);
|
|
va_end(ap);
|
|
snprintf(g_sync_errlog[g_sync_errn % 5], 160, "%s", buf);
|
|
g_sync_errn++;
|
|
log_line("sync: %s", buf);
|
|
}
|
|
|
|
/* one curl handle reused by the sync thread (bulk-check + uploads) */
|
|
static CURL *g_sync_curl;
|
|
static struct curl_slist *g_sync_hdrs;
|
|
|
|
static void sync_curl_init(void)
|
|
{
|
|
g_sync_curl = curl_easy_init();
|
|
char keyhdr[300];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
g_sync_hdrs = curl_slist_append(NULL, keyhdr);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_WRITEFUNCTION, write_cb);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_USERAGENT, "vitaImmich/0.1 (PS Vita)");
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
|
if (g_resolve_list)
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_RESOLVE, g_resolve_list);
|
|
}
|
|
|
|
/* parse a bulk-upload-check response: for each result, look up the local
|
|
* item by its id (we send the local index as the id) and set its state */
|
|
static void parse_bulk_check(const char *js, size_t jslen)
|
|
{
|
|
jsmn_parser parser;
|
|
jsmn_init(&parser);
|
|
int ntok = jsmn_parse(&parser, js, jslen, NULL, 0);
|
|
if (ntok <= 0)
|
|
return;
|
|
jsmntok_t *tok = malloc(sizeof(jsmntok_t) * ntok);
|
|
if (!tok)
|
|
return;
|
|
jsmn_init(&parser);
|
|
ntok = jsmn_parse(&parser, js, jslen, tok, ntok);
|
|
if (ntok <= 0) {
|
|
free(tok);
|
|
return;
|
|
}
|
|
|
|
/* walk objects, collecting id/action/reason/assetId per result */
|
|
int cur_obj = -1, li = -1;
|
|
char action[16], reason[24], assetid[40];
|
|
for (int i = 0; i <= ntok; i++) {
|
|
if (i == ntok || tok[i].type == JSMN_OBJECT) {
|
|
/* flush the previous object's result */
|
|
if (li >= 0 && li < g_local_count) {
|
|
if (!strcmp(action, "reject") &&
|
|
!strcmp(reason, "duplicate")) {
|
|
snprintf(g_local_server_id[li], 40,
|
|
"%s", assetid);
|
|
g_local_state[li] = SYNC_BACKED_UP;
|
|
} else {
|
|
/* accept, or a non-duplicate reject we
|
|
* can still try to upload */
|
|
g_local_state[li] = SYNC_LOCAL_ONLY;
|
|
}
|
|
g_need_rebuild = 1;
|
|
}
|
|
if (i == ntok)
|
|
break;
|
|
cur_obj = i;
|
|
li = -1;
|
|
action[0] = reason[0] = assetid[0] = '\0';
|
|
continue;
|
|
}
|
|
if (tok[i].type != JSMN_STRING || tok[i].size != 1 ||
|
|
tok[i].parent != cur_obj || i + 1 >= ntok)
|
|
continue;
|
|
int klen = tok[i].end - tok[i].start;
|
|
const char *k = js + tok[i].start;
|
|
jsmntok_t *v = &tok[i + 1];
|
|
int vlen = v->end - v->start;
|
|
const char *vs = js + v->start;
|
|
if (klen == 2 && !strncmp(k, "id", 2))
|
|
li = atoi(vs); /* we sent the local index */
|
|
else if (klen == 6 && !strncmp(k, "action", 6))
|
|
snprintf(action, sizeof(action), "%.*s", vlen, vs);
|
|
else if (klen == 6 && !strncmp(k, "reason", 6))
|
|
snprintf(reason, sizeof(reason), "%.*s", vlen, vs);
|
|
else if (klen == 7 && !strncmp(k, "assetId", 7))
|
|
snprintf(assetid, sizeof(assetid), "%.*s", vlen, vs);
|
|
}
|
|
free(tok);
|
|
}
|
|
|
|
/* bulk-upload-check a batch of CHECKING items (indices in idxs[0..n)) */
|
|
static void bulk_check_batch(const int *idxs, int n)
|
|
{
|
|
/* {"assets":[{"id":"<localidx>","checksum":"<hex>"},...]} */
|
|
size_t cap = 64 + n * 80;
|
|
char *body = malloc(cap);
|
|
if (!body)
|
|
return;
|
|
int off = snprintf(body, cap, "{\"assets\":[");
|
|
for (int i = 0; i < n; i++)
|
|
off += snprintf(body + off, cap - off,
|
|
"%s{\"id\":\"%d\",\"checksum\":\"%s\"}",
|
|
i ? "," : "", idxs[i], g_local_sha1[idxs[i]]);
|
|
off += snprintf(body + off, cap - off, "]}");
|
|
|
|
char url[600];
|
|
snprintf(url, sizeof(url), "%s/api/assets/bulk-upload-check", g_server);
|
|
|
|
struct curl_slist *hdrs = curl_slist_append(NULL, "Content-Type: application/json");
|
|
char keyhdr[300];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
hdrs = curl_slist_append(hdrs, keyhdr);
|
|
|
|
membuf buf = { NULL, 0 };
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_POST, 1L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_POSTFIELDS, body);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_WRITEDATA, &buf);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_TIMEOUT, 60L);
|
|
net_lock(); /* TLS is single-threaded, see g_net_mutex */
|
|
CURLcode res = curl_easy_perform(g_sync_curl);
|
|
net_unlock();
|
|
long code = 0;
|
|
if (res == CURLE_OK)
|
|
curl_easy_getinfo(g_sync_curl, CURLINFO_RESPONSE_CODE, &code);
|
|
|
|
if (res == CURLE_OK && code >= 200 && code < 300 && buf.data) {
|
|
parse_bulk_check(buf.data, buf.size);
|
|
} else {
|
|
/* leave them LOCAL_ONLY so the user can still try to upload */
|
|
for (int i = 0; i < n; i++)
|
|
g_local_state[idxs[i]] = SYNC_LOCAL_ONLY;
|
|
sync_push_err("dup-check failed (curl %d, HTTP %ld)", res, code);
|
|
}
|
|
g_need_rebuild = 1;
|
|
|
|
/* reset the handle for reuse (clear POSTFIELDS / restore defaults) */
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_HTTPHEADER, NULL);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_POSTFIELDS, NULL);
|
|
curl_slist_free_all(hdrs);
|
|
free(buf.data);
|
|
free(body);
|
|
}
|
|
|
|
static int upload_xfer_cb(void *ud, curl_off_t dl, curl_off_t dln,
|
|
curl_off_t ult, curl_off_t uln)
|
|
{
|
|
g_ul_total = ult;
|
|
g_ul_now = uln;
|
|
return 0;
|
|
}
|
|
|
|
/* upload one local item; returns 0 on success, -1 on failure (err set) */
|
|
static int upload_item(int j, char *err, size_t errlen)
|
|
{
|
|
const char *path = g_local_path[j];
|
|
const char *name = strrchr(path, '/');
|
|
name = name ? name + 1 : path;
|
|
|
|
char created[40], modified[40], devid[300];
|
|
datetime_iso(&g_local_ctime[j], created, sizeof(created));
|
|
datetime_iso(&g_local_mtime[j], modified, sizeof(modified));
|
|
snprintf(devid, sizeof(devid), "%s-%lld", name, g_local_size[j]);
|
|
|
|
char url[600];
|
|
snprintf(url, sizeof(url), "%s/api/assets", g_server);
|
|
|
|
char keyhdr[300], sumhdr[80];
|
|
snprintf(keyhdr, sizeof(keyhdr), "x-api-key: %s", g_apikey);
|
|
snprintf(sumhdr, sizeof(sumhdr), "x-immich-checksum: %s", g_local_sha1[j]);
|
|
struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr);
|
|
hdrs = curl_slist_append(hdrs, sumhdr);
|
|
hdrs = curl_slist_append(hdrs, "Accept: application/json");
|
|
|
|
curl_mime *mime = curl_mime_init(g_sync_curl);
|
|
curl_mimepart *part;
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "assetData");
|
|
curl_mime_filedata(part, path); /* streams from disk */
|
|
curl_mime_filename(part, name);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "deviceAssetId");
|
|
curl_mime_data(part, devid, CURL_ZERO_TERMINATED);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "deviceId");
|
|
curl_mime_data(part, "PS Vita", CURL_ZERO_TERMINATED);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "fileCreatedAt");
|
|
curl_mime_data(part, created, CURL_ZERO_TERMINATED);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "fileModifiedAt");
|
|
curl_mime_data(part, modified, CURL_ZERO_TERMINATED);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "filename");
|
|
curl_mime_data(part, name, CURL_ZERO_TERMINATED);
|
|
|
|
membuf buf = { NULL, 0 };
|
|
g_ul_now = g_ul_total = 0;
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_MIMEPOST, mime);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_WRITEDATA, &buf);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_TIMEOUT, 0L); /* big files */
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_LOW_SPEED_LIMIT, 512L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_LOW_SPEED_TIME, 60L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_NOPROGRESS, 0L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_XFERINFOFUNCTION, upload_xfer_cb);
|
|
|
|
net_lock(); /* TLS is single-threaded, see g_net_mutex */
|
|
CURLcode res = curl_easy_perform(g_sync_curl);
|
|
net_unlock();
|
|
long code = 0;
|
|
if (res == CURLE_OK)
|
|
curl_easy_getinfo(g_sync_curl, CURLINFO_RESPONSE_CODE, &code);
|
|
|
|
int rc = -1;
|
|
if (res != CURLE_OK) {
|
|
snprintf(err, errlen, "curl %d: %s", res, curl_easy_strerror(res));
|
|
} else if (code != 200 && code != 201) {
|
|
snprintf(err, errlen, "HTTP %ld: %.80s", code,
|
|
buf.data ? buf.data : "");
|
|
} else {
|
|
/* parse {"id":"...","status":"created|duplicate"} */
|
|
const char *p = buf.data ? strstr(buf.data, "\"id\"") : NULL;
|
|
if (p) {
|
|
p = strchr(p + 4, '"');
|
|
if (p) {
|
|
p++;
|
|
int k = 0;
|
|
while (p[k] && p[k] != '"' && k < 39) {
|
|
g_local_server_id[j][k] = p[k];
|
|
k++;
|
|
}
|
|
g_local_server_id[j][k] = '\0';
|
|
}
|
|
}
|
|
rc = 0;
|
|
}
|
|
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_MIMEPOST, NULL);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_NOPROGRESS, 1L);
|
|
curl_easy_setopt(g_sync_curl, CURLOPT_HTTPHEADER, NULL);
|
|
curl_mime_free(mime);
|
|
curl_slist_free_all(hdrs);
|
|
free(buf.data);
|
|
return rc;
|
|
}
|
|
|
|
/* the sync worker: hash everything, dup-check in batches, then drain the
|
|
* upload queue (items the user moved to QUEUED from the sync screen) */
|
|
static int sync_thread(SceSize args, void *argp)
|
|
{
|
|
sync_curl_init();
|
|
|
|
for (;;) {
|
|
/* 1. hash + dup-check a batch of freshly scanned files */
|
|
int batch[100], nb = 0;
|
|
for (int j = 0; j < g_local_count && nb < 100; j++) {
|
|
if (g_local_state[j] != SYNC_UNSCANNED)
|
|
continue;
|
|
const char *name = strrchr(g_local_path[j], '/');
|
|
name = name ? name + 1 : g_local_path[j];
|
|
g_sync_phase = 1;
|
|
snprintf(g_sync_activity, sizeof(g_sync_activity),
|
|
"hashing %.120s", name);
|
|
g_local_state[j] = SYNC_HASHING;
|
|
if (sha1_file_hex(g_local_path[j], g_local_sha1[j]) != 0) {
|
|
snprintf(g_local_err[j], sizeof(g_local_err[j]),
|
|
"hash failed");
|
|
g_local_state[j] = SYNC_FAILED;
|
|
sync_push_err("hash failed: %s", name);
|
|
continue;
|
|
}
|
|
g_local_state[j] = SYNC_CHECKING;
|
|
batch[nb++] = j;
|
|
}
|
|
if (nb > 0) {
|
|
g_sync_phase = 2;
|
|
snprintf(g_sync_activity, sizeof(g_sync_activity),
|
|
"checking %d files on server", nb);
|
|
bulk_check_batch(batch, nb);
|
|
continue; /* loop back for the next batch */
|
|
}
|
|
|
|
/* 2. drain the upload queue */
|
|
int up = -1;
|
|
for (int j = 0; j < g_local_count; j++)
|
|
if (g_local_state[j] == SYNC_QUEUED) {
|
|
up = j;
|
|
break;
|
|
}
|
|
if (up >= 0) {
|
|
const char *name = strrchr(g_local_path[up], '/');
|
|
name = name ? name + 1 : g_local_path[up];
|
|
g_local_state[up] = SYNC_UPLOADING;
|
|
g_sync_phase = 3;
|
|
snprintf(g_sync_activity, sizeof(g_sync_activity),
|
|
"uploading %.118s", name);
|
|
char err[96];
|
|
if (upload_item(up, err, sizeof(err)) == 0) {
|
|
g_local_state[up] = SYNC_BACKED_UP;
|
|
} else {
|
|
snprintf(g_local_err[up], sizeof(g_local_err[up]),
|
|
"%s", err);
|
|
g_local_state[up] = SYNC_FAILED;
|
|
sync_push_err("upload %s: %s", name, err);
|
|
}
|
|
g_need_rebuild = 1;
|
|
continue;
|
|
}
|
|
|
|
/* nothing to do */
|
|
g_sync_phase = 0;
|
|
g_sync_activity[0] = '\0';
|
|
sceKernelDelayThread(50 * 1000);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* main thread: queue every LOCAL_ONLY item for upload (the QUEUED handoff,
|
|
* the one transition the main thread is allowed to make) */
|
|
static int queue_all_uploads(void)
|
|
{
|
|
int n = 0;
|
|
for (int j = 0; j < g_local_count; j++)
|
|
if (g_local_state[j] == SYNC_LOCAL_ONLY) {
|
|
g_local_state[j] = SYNC_QUEUED;
|
|
n++;
|
|
}
|
|
__sync_synchronize();
|
|
return n;
|
|
}
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* grid status badges + sync overview */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* small colour dot in the top-left corner of a grid cell; a backed-up
|
|
* item also gets a tiny white check, a local-only item an up-arrow */
|
|
static void draw_status_badge(float bx, float by, int d, unsigned int frame)
|
|
{
|
|
struct disp_item *it = &g_disp[d];
|
|
uint32_t col;
|
|
int kind; /* 0 none, 1 cloud, 2 local, 3 busy, 4 backed, 5 failed */
|
|
|
|
if (it->src == SRC_SERVER) {
|
|
kind = g_asset_local_backed[it->idx] ? 4 : 1;
|
|
} else {
|
|
switch (g_local_state[it->idx]) {
|
|
case SYNC_LOCAL_ONLY: kind = 2; break;
|
|
case SYNC_QUEUED:
|
|
case SYNC_UPLOADING: kind = 3; break;
|
|
case SYNC_BACKED_UP: kind = 4; break;
|
|
case SYNC_FAILED: kind = 5; break;
|
|
default: kind = 0; break; /* hashing/checking */
|
|
}
|
|
}
|
|
if (kind == 0)
|
|
return;
|
|
|
|
switch (kind) {
|
|
case 1: col = RGBA8(120, 150, 200, 255); break; /* cloud: blue-grey */
|
|
case 2: col = RGBA8(240, 160, 40, 255); break; /* local: orange */
|
|
case 3: /* busy: blinking orange */
|
|
col = (frame / 20) & 1 ? RGBA8(240, 160, 40, 255)
|
|
: RGBA8(120, 90, 30, 255);
|
|
break;
|
|
case 4: col = RGBA8(80, 200, 100, 255); break; /* backed: green */
|
|
default: col = RGBA8(220, 70, 70, 255); break; /* failed: red */
|
|
}
|
|
|
|
float cx = bx + 14, cy = by + 14, r = 9;
|
|
vita2d_draw_fill_circle(cx, cy, r + 2, RGBA8(0, 0, 0, 160));
|
|
vita2d_draw_fill_circle(cx, cy, r, col);
|
|
|
|
uint32_t w = RGBA8(255, 255, 255, 255);
|
|
if (kind == 4) { /* check mark */
|
|
vita2d_draw_rectangle(cx - 4, cy, 3, 5, w);
|
|
vita2d_draw_rectangle(cx - 2, cy + 2, 3, 3, w);
|
|
vita2d_draw_rectangle(cx, cy - 1, 3, 6, w);
|
|
vita2d_draw_rectangle(cx + 2, cy - 4, 3, 5, w);
|
|
} else if (kind == 2 || kind == 3) { /* up arrow */
|
|
vita2d_draw_rectangle(cx - 1, cy - 4, 3, 9, w);
|
|
vita2d_draw_rectangle(cx - 4, cy - 1, 3, 3, w);
|
|
vita2d_draw_rectangle(cx + 2, cy - 1, 3, 3, w);
|
|
}
|
|
}
|
|
|
|
/* count local items in a given state */
|
|
static int count_state(int st)
|
|
{
|
|
int n = 0;
|
|
for (int j = 0; j < g_local_count; j++)
|
|
if (g_local_state[j] == st)
|
|
n++;
|
|
return n;
|
|
}
|
|
|
|
static const char *state_name(int st)
|
|
{
|
|
switch (st) {
|
|
case SYNC_UNSCANNED: return "not scanned";
|
|
case SYNC_HASHING: return "hashing";
|
|
case SYNC_CHECKING: return "checking server";
|
|
case SYNC_LOCAL_ONLY:return "local only";
|
|
case SYNC_QUEUED: return "queued";
|
|
case SYNC_UPLOADING: return "uploading";
|
|
case SYNC_BACKED_UP: return "backed up";
|
|
case SYNC_FAILED: return "failed";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
/* format a byte count as B / KB / MB / GB into `out` */
|
|
static void human_size(long long bytes, char *out, size_t len)
|
|
{
|
|
if (bytes >= 1024LL * 1024 * 1024)
|
|
snprintf(out, len, "%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
|
else if (bytes >= 1024LL * 1024)
|
|
snprintf(out, len, "%.1f MB", bytes / (1024.0 * 1024));
|
|
else if (bytes >= 1024)
|
|
snprintf(out, len, "%.1f KB", bytes / 1024.0);
|
|
else
|
|
snprintf(out, len, "%lld B", bytes);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* main */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
enum { MODE_GRID, MODE_DETAIL, MODE_SYNC };
|
|
|
|
int main(void)
|
|
{
|
|
vita2d_init();
|
|
vita2d_set_clear_color(RGBA8(16, 16, 16, 255));
|
|
g_font = vita2d_load_default_pgf();
|
|
|
|
/* analog mode so the left stick reports lx/ly for detail-view panning */
|
|
sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG);
|
|
|
|
/* 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);
|
|
}
|
|
|
|
if (load_config() != 0)
|
|
fatal_error("Edit it with VitaShell, then restart the app. "
|
|
"Create the API key in the Immich web UI under "
|
|
"Account Settings > API Keys.",
|
|
"Set your server and API key in " CONFIG_PATH);
|
|
|
|
/* 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);
|
|
}
|
|
|
|
show_status("Loading library from %s ...", g_server);
|
|
fetch_page(1);
|
|
if (g_asset_count == 0)
|
|
fatal_error(NULL, "Server returned no assets");
|
|
|
|
SceUID worker = sceKernelCreateThread("thumb_loader", worker_thread,
|
|
0x10000100, 256 * 1024, 0, 0, NULL);
|
|
if (worker >= 0)
|
|
sceKernelStartThread(worker, 0, NULL);
|
|
|
|
/* scan the Vita's own camera media (fast: paths + stat only), build
|
|
* the merged timeline, then let the sync thread hash + dup-check it */
|
|
show_status("Scanning local media...");
|
|
scan_local_media();
|
|
rebuild_display();
|
|
|
|
SceUID syncw = sceKernelCreateThread("sync_worker", sync_thread,
|
|
0x10000100, 256 * 1024, 0, 0, NULL);
|
|
if (syncw >= 0)
|
|
sceKernelStartThread(syncw, 0, NULL);
|
|
|
|
int mode = MODE_GRID;
|
|
int sync_return_mode = MODE_GRID;
|
|
int sel = 0;
|
|
int grid_last_sel = -1; /* sel at the previous frame */
|
|
int grid_settle = 0; /* frames since sel last changed */
|
|
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_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;
|
|
|
|
for (;;) {
|
|
SceCtrlData pad;
|
|
sceCtrlPeekBufferPositive(0, &pad, 1);
|
|
unsigned int pressed = pad.buttons & ~prev_buttons;
|
|
prev_buttons = pad.buttons;
|
|
|
|
if (pad.buttons & SCE_CTRL_START)
|
|
break;
|
|
frame++;
|
|
|
|
/* 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;
|
|
int had = (sel >= 0 && sel < g_disp_count);
|
|
struct disp_item keep = had ? g_disp[sel]
|
|
: (struct disp_item){ 0, 0 };
|
|
rebuild_display();
|
|
if (had) {
|
|
int ns = find_disp(keep.src, keep.idx);
|
|
if (ns >= 0)
|
|
sel = ns;
|
|
}
|
|
if (sel >= g_disp_count)
|
|
sel = g_disp_count - 1;
|
|
detail_idx = -1; /* re-decode: item under sel may differ */
|
|
}
|
|
|
|
/* SELECT toggles the sync overview from the grid / detail view */
|
|
if ((pressed & SCE_CTRL_SELECT) && mode != MODE_SYNC) {
|
|
sync_return_mode = mode;
|
|
mode = MODE_SYNC;
|
|
continue;
|
|
}
|
|
|
|
/* 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;
|
|
|
|
if (mode == MODE_GRID) {
|
|
if (nav & SCE_CTRL_RIGHT)
|
|
sel++;
|
|
if (nav & SCE_CTRL_LEFT)
|
|
sel--;
|
|
if (nav & SCE_CTRL_DOWN)
|
|
sel += COLS;
|
|
if (nav & SCE_CTRL_UP)
|
|
sel -= COLS;
|
|
if (sel < 0)
|
|
sel = 0;
|
|
if (sel >= g_disp_count)
|
|
sel = g_disp_count - 1;
|
|
if (pressed & SCE_CTRL_RTRIGGER)
|
|
sel = month_jump(sel, +1); /* older month */
|
|
if (pressed & SCE_CTRL_LTRIGGER)
|
|
sel = month_jump(sel, -1); /* newer month */
|
|
|
|
/* 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++;
|
|
}
|
|
int scrolling_fast = (grid_settle < 12);
|
|
|
|
if (pressed & SCE_CTRL_CROSS) {
|
|
if (g_disp_count > 0 && disp_is_video(sel)) {
|
|
/* videos start loading/playing right
|
|
* away; no "press X to play" stop */
|
|
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;
|
|
zoom = 1.0f;
|
|
panx = pany = 0.0f;
|
|
}
|
|
|
|
/* 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 && g_next_page > 0 &&
|
|
sel >= g_disp_count - COLS * 4) {
|
|
if (fetch_page(0) > 0)
|
|
rebuild_display();
|
|
if (sel >= g_disp_count)
|
|
sel = g_disp_count - 1;
|
|
}
|
|
|
|
/* 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;
|
|
if (sel_y - HEADER_H < target)
|
|
target = sel_y - HEADER_H;
|
|
if (sel_y + CELL_H > target + SCREEN_H)
|
|
target = sel_y + CELL_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;
|
|
|
|
/* 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] - scroll;
|
|
if (dy > -CELL_H && 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;
|
|
}
|
|
|
|
/* 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 - scroll;
|
|
if (hy + HEADER_H < 0 || hy > SCREEN_H)
|
|
continue;
|
|
vita2d_pgf_draw_text(g_font, 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] - scroll;
|
|
float bx = x + CELL_PAD, by = y + CELL_PAD;
|
|
float bw = CELL_W - 2 * CELL_PAD;
|
|
float bh = CELL_H - 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));
|
|
vita2d_pgf_draw_text(g_font, bx + 6,
|
|
by + bh - 7,
|
|
RGBA8(255, 255, 255, 255),
|
|
0.85f, "VIDEO");
|
|
}
|
|
draw_status_badge(bx, by, i, frame);
|
|
if (i == sel)
|
|
draw_sel_outline(x + 2, y + 2,
|
|
CELL_W - 4, CELL_H - 4);
|
|
}
|
|
|
|
char hud[160];
|
|
snprintf(hud, sizeof(hud),
|
|
"%d / %d%s %.10s X view SELECT sync START exit",
|
|
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 (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;
|
|
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 (nav & SCE_CTRL_RIGHT && sel < g_disp_count - 1)
|
|
sel++;
|
|
if (nav & SCE_CTRL_LEFT && sel > 0)
|
|
sel--;
|
|
}
|
|
if (pressed & SCE_CTRL_CIRCLE) {
|
|
/* back to the grid; if a full-res load is in
|
|
* flight its result gets discarded there */
|
|
mode = MODE_GRID;
|
|
detail_idx = -1;
|
|
detail_loading = 0;
|
|
continue;
|
|
}
|
|
if (pressed & SCE_CTRL_CROSS) {
|
|
if (detail_failed) {
|
|
detail_idx = -1; /* retry */
|
|
} else if (is_video) {
|
|
if (is_local)
|
|
play_video_file(g_local_path[it.idx]);
|
|
else
|
|
view_video(it.idx);
|
|
/* 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;
|
|
}
|
|
}
|
|
|
|
/* 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);
|
|
|
|
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 load asynchronously on the worker;
|
|
* the blown-up grid thumb shows meanwhile and
|
|
* O stays responsive (videos: X plays) */
|
|
detail_loading = !is_video;
|
|
detail_issued = 0;
|
|
detail_idx = sel;
|
|
}
|
|
|
|
/* drive the async full-res load */
|
|
if (detail_loading) {
|
|
if (!detail_issued) {
|
|
if (g_req_state == REQ_DONE) {
|
|
__sync_synchronize();
|
|
consume_worker_result();
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
|
|
if (detail_tex) {
|
|
draw_texture_zoom(detail_tex, zoom, &panx, &pany);
|
|
} else if (is_video) {
|
|
draw_centered(SCREEN_H / 2,
|
|
RGBA8(200, 200, 200, 255),
|
|
"Press X to play this video");
|
|
} else if (detail_loading) {
|
|
/* low-res grid thumb blown up while the full
|
|
* image is still on its way */
|
|
vita2d_texture *th = disp_thumb(sel);
|
|
if (th)
|
|
draw_texture_fitted(th, 0, 0,
|
|
SCREEN_W, SCREEN_H);
|
|
else
|
|
draw_centered(SCREEN_H / 2,
|
|
RGBA8(160, 160, 160, 255),
|
|
"Loading...");
|
|
} 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 = detail_failed ? "X retry " :
|
|
is_video ? "X play " : "";
|
|
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 %s< > browse L/R zoom %sO back",
|
|
sel + 1, g_disp_count,
|
|
g_disp_count > 0 ? disp_date(sel) : "",
|
|
detail_loading ? "loading... " : "",
|
|
xhint);
|
|
draw_hud(hud);
|
|
|
|
vita2d_end_drawing();
|
|
vita2d_swap_buffers();
|
|
} else { /* MODE_SYNC */
|
|
if ((pressed & SCE_CTRL_CIRCLE) ||
|
|
(pressed & SCE_CTRL_SELECT)) {
|
|
mode = sync_return_mode;
|
|
detail_idx = -1; /* re-decode on return to detail */
|
|
continue;
|
|
}
|
|
if (pressed & SCE_CTRL_CROSS) {
|
|
int q = queue_all_uploads();
|
|
log_line("sync: queued %d uploads", q);
|
|
}
|
|
|
|
int backed = count_state(SYNC_BACKED_UP);
|
|
int local_only = count_state(SYNC_LOCAL_ONLY);
|
|
int queued = count_state(SYNC_QUEUED) +
|
|
count_state(SYNC_UPLOADING);
|
|
int failed = count_state(SYNC_FAILED);
|
|
int pending = count_state(SYNC_UNSCANNED) +
|
|
count_state(SYNC_HASHING) +
|
|
count_state(SYNC_CHECKING);
|
|
|
|
/* photo/video split + total and backed-up byte totals */
|
|
int srv_vid = 0;
|
|
for (int i = 0; i < g_asset_count; i++)
|
|
srv_vid += g_asset_is_video[i] ? 1 : 0;
|
|
int loc_vid = 0;
|
|
long long loc_bytes = 0, backed_bytes = 0;
|
|
for (int j = 0; j < g_local_count; j++) {
|
|
loc_vid += g_local_is_video[j] ? 1 : 0;
|
|
loc_bytes += g_local_size[j];
|
|
if (g_local_state[j] == SYNC_BACKED_UP)
|
|
backed_bytes += g_local_size[j];
|
|
}
|
|
int loc_pct = g_local_count ? (backed * 100 / g_local_count) : 0;
|
|
|
|
vita2d_start_drawing();
|
|
vita2d_clear_screen();
|
|
|
|
draw_centered(40, RGBA8(255, 255, 255, 255), "Sync overview");
|
|
|
|
char line[200], sz1[24], sz2[24];
|
|
int y = 84;
|
|
uint32_t c = RGBA8(220, 220, 220, 255);
|
|
snprintf(line, sizeof(line),
|
|
"Server assets: %d%s (%d photos, %d videos)",
|
|
g_asset_count, g_next_page > 0 ? "+" : "",
|
|
g_asset_count - srv_vid, srv_vid);
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 1.0f, line); y += 30;
|
|
human_size(loc_bytes, sz1, sizeof(sz1));
|
|
snprintf(line, sizeof(line),
|
|
"Local files: %d (%d photos, %d videos) %s",
|
|
g_local_count, g_local_count - loc_vid, loc_vid, sz1);
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 1.0f, line); y += 30;
|
|
snprintf(line, sizeof(line),
|
|
"backed up %d local-only %d queued/uploading %d",
|
|
backed, local_only, queued);
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 1.0f, line); y += 30;
|
|
snprintf(line, sizeof(line), "scanning/hashing %d failed %d",
|
|
pending, failed);
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 1.0f, line); y += 30;
|
|
human_size(backed_bytes, sz1, sizeof(sz1));
|
|
human_size(loc_bytes, sz2, sizeof(sz2));
|
|
snprintf(line, sizeof(line),
|
|
"Backed up: %d%% of local (%s / %s)",
|
|
loc_pct, sz1, sz2);
|
|
vita2d_pgf_draw_text(g_font, 60, y,
|
|
RGBA8(120, 200, 120, 255), 1.0f, line);
|
|
y += 38;
|
|
|
|
/* details of the item currently selected in the grid */
|
|
if (g_disp_count > 0 && sel >= 0 && sel < g_disp_count) {
|
|
struct disp_item *si = &g_disp[sel];
|
|
const char *name;
|
|
if (si->src == SRC_LOCAL) {
|
|
const char *p = g_local_path[si->idx];
|
|
const char *slash = strrchr(p, '/');
|
|
name = slash ? slash + 1 : p;
|
|
} else {
|
|
name = g_asset_ids[si->idx];
|
|
}
|
|
snprintf(line, sizeof(line), "Selected: %.40s", name);
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 0.95f, line);
|
|
y += 26;
|
|
if (si->src == SRC_LOCAL) {
|
|
human_size(g_local_size[si->idx], sz1,
|
|
sizeof(sz1));
|
|
snprintf(line, sizeof(line),
|
|
" %.19s %s %s%s",
|
|
g_local_date[si->idx], sz1,
|
|
g_local_is_video[si->idx] ?
|
|
"video, " : "photo, ",
|
|
state_name(g_local_state[si->idx]));
|
|
} else {
|
|
snprintf(line, sizeof(line),
|
|
" %.19s %s on server",
|
|
g_asset_dates[si->idx],
|
|
g_asset_is_video[si->idx] ?
|
|
"video" : "photo");
|
|
}
|
|
vita2d_pgf_draw_text(g_font, 60, y, c, 0.95f, line);
|
|
}
|
|
y += 36;
|
|
|
|
/* current activity + upload progress */
|
|
if (g_sync_activity[0]) {
|
|
if (g_sync_phase == 3 && g_ul_total > 0) {
|
|
float frac = (float)g_ul_now / (float)g_ul_total;
|
|
snprintf(line, sizeof(line), "%s %.0f%%",
|
|
g_sync_activity, frac * 100.0f);
|
|
} else {
|
|
snprintf(line, sizeof(line), "%s",
|
|
g_sync_activity);
|
|
}
|
|
vita2d_pgf_draw_text(g_font, 60, y,
|
|
RGBA8(120, 200, 120, 255),
|
|
1.0f, line);
|
|
}
|
|
y += 44;
|
|
|
|
if (g_sync_errn > 0) {
|
|
vita2d_pgf_draw_text(g_font, 60, y,
|
|
RGBA8(220, 120, 120, 255),
|
|
1.0f, "Recent errors:");
|
|
y += 30;
|
|
int shown = g_sync_errn < 5 ? g_sync_errn : 5;
|
|
for (int k = 0; k < shown && y < 480; k++) {
|
|
int e = (g_sync_errn - shown + k) % 5;
|
|
vita2d_pgf_draw_text(g_font, 60, y,
|
|
RGBA8(200, 160, 160, 255),
|
|
0.9f, g_sync_errlog[e]);
|
|
y += 26;
|
|
}
|
|
}
|
|
|
|
draw_hud("X upload all local-only O / SELECT back START exit");
|
|
|
|
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;
|
|
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]);
|
|
vita2d_free_pgf(g_font);
|
|
vita2d_fini();
|
|
curl_global_cleanup();
|
|
if (g_photo0_mounted)
|
|
sceAppMgrUmount("photo0:");
|
|
sceKernelExitProcess(0);
|
|
return 0;
|
|
}
|