Compare commits

..
10 Commits
Author SHA1 Message Date
ninoandClaude Sonnet 4.6 07890191cf Add albums support, API layer, and Docker improvements
- Add albums browsing and navigation (src/albums.c, src/app.c)
- Extract API calls into dedicated src/api.c module
- Improve display and UI components
- Update CMakeLists.txt to include new source files
- Refactor Dockerfile for better build process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-23 14:11:00 -06:00
SadsandClaude Opus 4.8 f4cf235c40 docs: flashier README with screenshots
Restyle the README: centered hero header, a screenshot row (timeline,
Places map, server/backup), and an emoji feature list. Refresh the feature
set and controls for what shipped since (smart search, the Places map +
cluster gallery, analog-stick map control) and link the releases page.
Screenshots live in docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
2026-06-18 23:31:06 +02:00
SadsandClaude Opus 4.8 b6eb700248 Places map: analog-stick control + gallery loads in place
- Left stick pans (proportional, in screen px) and the right stick up/down
  zooms, mirroring the detail view's feel; d-pad and L/R triggers still work.
- Cluster gallery: a member is placed on the grid only once its date/aspect
  have resolved, so it drops into its final month section instead of showing
  as an "unknown" placeholder that reflows the whole grid as metadata arrives.
  Show "Loading photos..." until the first members come in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
2026-06-18 23:15:04 +02:00
SadsandClaude Opus 4.8 9f647c034a Places map HUD: back glyph, zoom indicator, Open button; AA discs
- Top-right "Back" + O glyph (drawn and tappable), matching the other pages.
- Bottom-left X "Open" affordance; bottom-right "L <zoom%> R" indicator with L
  and R pinned so the changing percentage can't shift them.
- Drop the old verbose hint line.
- Anti-alias the DualShock-style button discs: enlarge the size-keyed disc
  cache (8 -> 24) and pre-warm the button sizes at init, so cluster bubbles
  can no longer fill it before the buttons draw (which had fallen back to the
  aliased fill_circle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
2026-06-18 22:52:26 +02:00
SadsandClaude Opus 4.8 5a0eb06d3e Places map: city base map, cluster gallery, zoom + render polish
Base map / tiles:
- Switch the base map to the mapchart city-label world map; re-derive its
  Web-Mercator georeferencing (MAP_LON0/1, MAP_IMG_MY0/1) by least-squares
  against the pixel-identical graticule export.
- Add a 1.5x LANCZOS supersample LOD and bump tile quality 90->94 so city
  labels stay crisp when zoomed in (tools/png_to_tiles.py).
- Fill the map view background with the ocean colour instead of black.

Navigation / clustering:
- Snap the cluster grid to power-of-two zoom octaves so the bubbles stop
  jittering during a continuous zoom.
- Replace the loose zoom clamps with named MAP_SCALE_MIN/MAX (whole map fills
  the screen .. country/region level).
- Add a toggleable computed lat/lon graticule overlay (TRIANGLE).

Cluster gallery:
- Selecting a bubble now opens all its photos on the same justified grid as
  the timeline/search (a g_gallery_active filter). Members not already paged
  in are appended as hidden g_asset entries (kept out of the main timeline);
  a background metadata worker resolves each one's date/aspect so the grid
  reflows into month sections + true aspect ratios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
2026-06-18 22:26:59 +02:00
SadsandClaude Opus 4.8 02f428a76d config: parse config.txt by hand instead of fgets()
Read the whole file with fread() and split on '\n' manually rather than using
fgets() per line. Originally a workaround for a Vita3K *web* (wasm interpreter)
bug that mis-scanned fgets's newline (so server= captured the whole file); that
interpreter bug is since fixed, so this is now redundant on the web build, but
it's correct and harmless on native Vita too — keeping it as a defensive,
emulator-independent parser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 02:24:07 +02:00
SadsandClaude Opus 4.8 66b9d77ac4 Add geotag "Places" map with raster LOD tile pyramid
New src/map.c (unity-build part) adds a map screen reached from the search
bar's map button:
- Fetches geotag markers (GET /api/map/markers), projects with Web Mercator.
- Clusters markers into count "bubbles" in map space (anchored to the world,
  so panning translates them instead of reshuffling); pan (d-pad/touch),
  zoom (L/R/pinch). X or tap zooms into a cluster, or opens a lone/co-located
  photo — fetching that asset on demand if it isn't paged into the grid —
  and returns to the map on back.
- Base map: a Web-Mercator LOD tile pyramid generated offline from a MapChart
  PNG (tools/png_to_tiles.py -> assets/maptiles.pak); the level matching the
  on-screen scale is drawn from 256px JPEG tiles decoded into pooled textures
  (the crash-safe path), with a coarse overview underneath so there are no
  gaps while tiles stream in. Georeferencing (full longitude, lat band) was
  fit from isolated-island centroids so markers land on land.

main.c uses a larger vita2d vertex pool; app.c wires the mode in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:24:03 +02:00
SadsandClaude Opus 4.8 351804819b Video bitrate probe, loading throbbers, boot spinner, transparent logo
- Video: probe the MP4 header (range request for moov) before downloading
  the whole clip; if the average bitrate overruns the declared H.264 level
  the Vita's decoder enforces, show the "too high" message up front instead
  of downloading then failing at playback. Verdict validated against the
  real demo videos (e.g. e75d495e: Level 3.1 @ 18.8 Mbps -> rejected).
- Error screens: word-wrap title + detail so long messages no longer run
  off the edges; the bitrate message now spells out the server-side fix
  (Immich transcoding policy / bitrate cap).
- Boot: one continuous spinner. A single boot worker thread does sign-in +
  first page + local scan while the main thread animates one logo with a
  never-resetting frame counter, instead of three separate static screens.
- Throbbers: the cloud/server-info page and smart search now fetch on a
  worker thread and animate a throbber instead of freezing the UI.
- Logo: swap the in-app logo for a clean 200x200 transparent PNG (the old
  one was matted from the white-background icon and had fringe); keep the
  launcher icon's white background. Linear-filter the logo + cloud badges
  so they scale/rotate smoothly.
- Drop AgenticLoop.md (and its CLAUDE.md reference).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:48:42 +02:00
SadsandClaude Opus 4.8 341d913346 Refactor: split monolithic main.c into a unity build
main.c was a single 6018-line translation unit. Cut it at the existing
section banners into ordered part files (state, draw, config, net, jpeg,
api, thumbs, display, ui, video, sync, app) that main.c #includes in
dependency order.

The parts form one translation unit and share all file-scope state, so
there are no headers, no extern decls, and no de-static-ing of the ~100
shared globals coupling the three threads. Only src/main.c is compiled
(CMakeLists unchanged). The slices are byte-exact: the preprocessed TU is
identical to the old main.c, so the binary is unchanged. Verified by a
clean build plus a Vita3K run (login, paging, grid, detail view, back nav,
no crash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:48:51 +02:00
SadsandClaude Opus 4.8 f145b40471 Docs: add CLAUDE.md build/run/drive loop; fix Vita3K MCP Wayland env + frame storage
- New CLAUDE.md documenting the build -> install -> launch -> drive -> logs loop.
- session_start must force SDL_VIDEODRIVER=wayland + empty DISPLAY, else Vita3K
  renders to the host X display (blank frames, dropped input).
- Native build now targets build/ instead of build-native/.
- Capture frames into agent-run/frames/ (wiped each session) and clear the
  /tmp/kwin-mcp-screenshots-* folder afterwards instead of leaving them in /tmp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:24:28 +02:00
27 changed files with 9095 additions and 6134 deletions
-124
View File
@@ -1,124 +0,0 @@
# Agentic loop: build, run & drive vitaImmich in Vita3K
This is the loop an agent uses to make a change, run it in the Vita3K emulator,
and verify it by sending input + reading screenshots — all headless, on KDE
Plasma (Wayland).
## One-time setup
- VitaSDK (community): native toolchain at `~/vitasdk` (`arm-vita-eabi-gcc`,
`vita-pack-vpk`, toolchain at `share/vita.toolchain.cmake`).
- Vita3K installed and on `PATH` (`/usr/bin/vita3k`).
- The MCP that drives the GUI: `uv tool install kwin-mcp` (configured in
`.mcp.json` as the `kwin-mcp` server).
## 1. Build the VPK → `build/vitaImmich.vpk`
Preferred (Docker, reproducible):
```bash
./build.sh # writes build/vitaImmich.vpk (build/ ends up root-owned)
```
Fallback when the Docker runtime is broken (e.g. containerd shim error
`readlink /proc/self/exe: no such file or directory`), build natively with the
host SDK into `build-native/` (git-ignored):
```bash
export VITASDK="$HOME/vitasdk"
export PATH=$VITASDK/bin:$PATH
cmake -B build-native -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=$VITASDK/share/vita.toolchain.cmake .
cmake --build build-native --parallel "$(nproc)"
```
## 2. Install the VPK into Vita3K
Installing = launch Vita3K once with the `.vpk` as an argument; it extracts and
auto-boots. Gotchas:
- Run it **from a script file**, backgrounded with a wait then killed. Inline
backgrounding from the agent's shell does not persist.
- Guard `pkill` with `|| true` — a no-match exit code aborts the chain.
- Vita3K extracts files preserving the **zip's internal mtime**, so to confirm a
fresh install compare the installed `eboot.bin` mtime against
`unzip -l <vpk> | grep eboot`, not wall-clock time.
```bash
#!/bin/bash
cd "$(git rev-parse --show-toplevel)"
pkill -9 -f vita3k 2>/dev/null || true
sleep 1
vita3k ./build-native/vitaImmich.vpk >/tmp/vinst.log 2>&1 & # or ./build/...
sleep 8
pkill -9 -f vita3k 2>/dev/null || true
sleep 1
grep -i "successfully" /tmp/vinst.log | tail -1
stat -c 'eboot: %y' ~/.local/share/Vita3K/Vita3K/ux0/app/VIMM00001/eboot.bin
```
App title id: **`VIMM00001`**. To wipe a stale install: `vita3k -d VIMM00001`.
## 3. Launch + drive via kwin-mcp
Launch the app **inside the MCP's own isolated KWin session** — its screenshot
tool works there. (Connecting to the live desktop with `session_connect` does
*not* work for capture: `spectacle` can't save non-interactively, and KWin's
`ScreenShot2` DBus API rejects unauthorized callers — so the isolated session is
the only reliable way to capture, and it is headless: the human cannot watch it
live on their monitor.)
- `session_start` with `app_command: "vita3k -r VIMM00001"` **and
`keep_screenshots: true`** — starts an isolated Wayland session, launches the
app, and keeps every captured frame on disk after `session_stop`. Give it ~12s
to boot.
- **`focus_window` with `app_name: "Vita3K"` BEFORE sending keys.** Without focus
the keypresses are dropped. Presses also drop intermittently — re-focus and
re-press. Send one key per call; rapid bursts get coalesced/dropped.
- `keyboard_key` with `screenshot_after_ms: [ms]` after **every** command, so the
frame folder is a complete visual record of the run for the human to scrub.
- `session_stop` when done (and `pkill -9 -f vita3k`). Frames survive in
`/tmp/kwin-mcp-screenshots-*/`; copy them to `./agent-run/` (git-ignored) and
tell the human where they are.
### Zero-context-cost capture (the standing rule)
Capturing a frame (writing the PNG) costs the agent **nothing** — files on disk
are not in context. The cost is only incurred when the agent **Reads** a PNG
back. So:
- **Capture every command** (`screenshot_after_ms`) — free, and it gives the
human the full record.
- **Do not Read frames back** into context during the loop. Verify progress from
the app's own **text** log instead (cheap), not from images:
`~/.local/share/Vita3K/Vita3K/ux0/data/vitaimmich/log.txt`.
- Only Read a frame when a screenshot is genuinely the only way to resolve an
ambiguity (rare). Reading every frame is what bloats context and slows the
session — that is exactly what this rule avoids.
### Key mapping (full list in `controls.MD`)
Vita3K maps host keys → Vita buttons. The ones this app uses:
| Vita button | host key (kwin-mcp) | in-app action |
|-------------|---------------------|--------------------------|
| D-pad | `Right` `Left` `Up` `Down` | move grid / browse |
| Cross (X) | `x` | view / play / replay |
| Circle (O) | `c` | back |
| Select | `Shift_R` | sync |
| Start | `Return` | exit |
### Typical verify loop (capture everything, Read nothing)
1. `session_start` (`keep_screenshots: true`) → wait ~12s.
2. `focus_window "Vita3K"`.
3. `keyboard_key "Right"` (with `screenshot_after_ms`) … stepping to the target
tile, one key per call.
4. `keyboard_key "x"` to open, `x` again to play.
5. Confirm what happened from the **text** log, not the frames:
- `~/.local/share/Vita3K/Vita3K/ux0/data/vitaimmich/log.txt` (the app's own log
— records page fetches, video open/play, scans, errors)
- MCP `read_app_log` with the launched PID, or `/tmp/vita3k-runtime.log`.
6. On `session_stop`, the captured frames remain in
`/tmp/kwin-mcp-screenshots-*/` — hand that folder (or a copy in `./agent-run/`)
to the human as the visual record.
+165
View File
@@ -0,0 +1,165 @@
# CLAUDE.md — vitaImmich
PS Vita Immich client (PoC). C sources in `src/`. It's a **unity build**:
`main.c` holds the includes, `#define`s and core globals, then `#include`s the
section files (`state.c`, `draw.c`, `config.c`, `net.c`, `jpeg.c`, `api.c`,
`thumbs.c`, `display.c`, `ui.c`, `video.c`, `sync.c`, `app.c`) in order. They
form **one translation unit** and share all file-scope state — there are no
headers or `extern` decls. **Only `src/main.c` is compiled** (CMakeLists lists
just it); the parts are textual slices, so add new code to the relevant part,
not to CMake. `jsmn.h` is vendored.
Built with the VitaSDK toolchain into a `.vpk`, run in the **Vita3K** emulator,
and driven headlessly through the **kwin-mcp** server. This file is the
end-to-end loop: **build → install → launch → drive → read logs.** Full key
map is in `controls.MD`.
App title id: **`VIMM00001`**.
---
## 1. Build the changes → a `.vpk`
After editing anything in `src/` (or `CMakeLists.txt`), rebuild.
**Preferred — Docker (reproducible), writes `build/vitaImmich.vpk`:**
```bash
./build.sh
```
> `build/` ends up root-owned. If the Docker runtime is broken (containerd shim
> `readlink /proc/self/exe: no such file or directory`), use the native fallback.
**Fallback — native host SDK, also writes `build/vitaImmich.vpk`:**
```bash
export VITASDK="$HOME/vitasdk"
export PATH="$VITASDK/bin:$PATH"
cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE="$VITASDK/share/vita.toolchain.cmake" .
cmake --build build --parallel "$(nproc)"
```
> If a prior Docker build left `build/` root-owned, clear it first
> (`sudo rm -rf build`) so the native build can write there.
Whichever path you use, the next step installs that `.vpk`.
---
## 2. Install the rebuilt VPK into Vita3K
Installing = launch Vita3K once with the `.vpk` as an argument; it extracts and
auto-boots. `deploy.sh` does install-then-launch in one shot:
```bash
./deploy.sh # installs ./build/vitaImmich.vpk, then launches VIMM00001 detached
```
Gotchas (these are why it must run from a script, not inline):
- Background it from a **script file** with a `sleep`/`wait` then `pkill`. Inline
backgrounding from the agent's shell does not persist.
- Guard `pkill` with `|| true` — a no-match exit code aborts the chain.
- Vita3K preserves the **zip's internal mtime** on extract. To confirm a *fresh*
install, compare the installed `eboot.bin` mtime against the vpk entry, not
wall-clock:
```bash
unzip -l build/vitaImmich.vpk | grep eboot
stat -c 'eboot: %y' ~/.local/share/Vita3K/Vita3K/ux0/app/VIMM00001/eboot.bin
```
- Wipe a stale install: `vita3k -d VIMM00001`.
> `deploy.sh` launches detached on the **live desktop**. For agent-driven
> verification with screenshots, **skip `deploy.sh`'s launch** and instead launch
> inside the MCP's isolated session (step 3) — only do the *install* half first
> (`vita3k ./build/vitaImmich.vpk` for ~4s, then `pkill`).
---
## 3. Launch + drive via kwin-mcp (screenshots + keystrokes)
Launch the app **inside kwin-mcp's own isolated KWin session** — its screenshot
tool only works there. `session_connect` to the live desktop does **not** capture
(spectacle can't save non-interactively; KWin's `ScreenShot2` DBus rejects
unauthorized callers). The isolated session is headless: the human cannot watch
it live, which is exactly why we capture every frame to disk.
0. **Wipe the per-session frame folder first:**
`rm -rf agent-run/frames && mkdir -p agent-run/frames`. (`agent-run/` is
git-ignored; `frames/` is the throwaway capture folder — your curated keepers
stay in `agent-run/` proper and are never touched.)
1. **`session_start`** with `app_command: "vita3k -r VIMM00001"`,
`keep_screenshots: true`, **and `env: {"SDL_VIDEODRIVER": "wayland",
"DISPLAY": ""}`** — starts the isolated session, launches the app, and keeps
every captured frame after `session_stop`. Give it **~12s** to boot.
> **The `env` is mandatory.** The session inherits the host's `DISPLAY=:1`, so
> without it Vita3K's SDL backend picks X11 and renders to the *host* display —
> the captured frames come back **blank white**, `focus_window "Vita3K"` fails
> ("no application matching"), and keystrokes never reach the app. Forcing
> `SDL_VIDEODRIVER=wayland` and clearing `DISPLAY` makes Vita3K render into the
> MCP's Wayland session, where capture and input both work. Sanity check:
> `list_windows` should show a `Vita3K` window and frames should be ~300–500 KB,
> not ~8 KB.
2. **`focus_window` with `app_name: "Vita3K"` BEFORE every key.** Without focus,
keypresses are dropped. They also drop intermittently — re-focus and re-press.
**One key per call**; rapid bursts get coalesced/dropped.
3. **`keyboard_key`** with `screenshot_after_ms: <ms>` after **every** command,
so the frame folder is a complete visual record.
4. **`session_stop`** when done, then `pkill -9 -f vita3k`. kwin-mcp always writes
frames to `/tmp/kwin-mcp-screenshots-*/` (not configurable) — move them into the
repo and clear tmp, then tell the human where they are:
```bash
cp /tmp/kwin-mcp-screenshots-*/*.png agent-run/frames/ 2>/dev/null
rm -rf /tmp/kwin-mcp-screenshots-*
```
### Key map (this app's actions; full list in `controls.MD`)
| Vita button | host key (kwin-mcp) | in-app action |
|-------------|------------------------------|----------------------|
| D-pad | `Right` `Left` `Up` `Down` | move grid / browse |
| Cross (X) | `x` | view / play / replay |
| Circle (O) | `c` | back |
| Select | `Shift_R` | sync |
| Start | `Return` | exit |
---
## 4. Read the logs or screenshots
**Capturing a frame is free** (PNGs on disk are not in context). **Reading a PNG
back into context is the expensive part.** So capture every command, but **do not
Read frames back** during the loop. Verify progress from the app's own **text**
log instead:
```
~/.local/share/Vita3K/Vita3K/ux0/data/vitaimmich/log.txt
```
This is the app's own log — it records page fetches, video open/play, scans, and
errors. Also available:
- MCP **`read_app_log`** with the launched PID.
- `/tmp/vita3k-runtime.log` (Vita3K runtime stdout/stderr from `deploy.sh`).
- `/tmp/vita3k-install.log` (install output).
read the screenshot if its usefull for the current task
### Typical verify loop
1. Build (step 1) → install half of step 2.
2. `rm -rf agent-run/frames && mkdir -p agent-run/frames`, then `session_start`
(`keep_screenshots: true`, `env` forcing Wayland) → wait ~12s.
3. `focus_window "Vita3K"`.
4. `keyboard_key "Right"` (+ `screenshot_after_ms`), stepping to the target tile,
one key per call; `x` to open, `x` again to play.
5. Confirm what happened from `log.txt` — **not** the frames.
6. `session_stop` + `pkill`; move frames into the repo and clear tmp
(`cp /tmp/kwin-mcp-screenshots-*/*.png agent-run/frames/ 2>/dev/null && rm -rf /tmp/kwin-mcp-screenshots-*`),
then hand `agent-run/frames/` to the human as the visual record.
---
+5
View File
@@ -32,6 +32,10 @@ target_link_libraries(${PROJECT_NAME}
jpeg jpeg
z z
m m
# OpenSSL 1.0.2's libcrypto.a has a threads_pthread.o that references
# pthread_getspecific/pthread_self/pthread_equal; the Vita newlib stubs that
# live in libpthread.a.
pthread
SceDisplay_stub SceDisplay_stub
SceGxm_stub SceGxm_stub
SceSysmodule_stub SceSysmodule_stub
@@ -60,4 +64,5 @@ vita_create_vpk(${PROJECT_NAME}.vpk ${VITA_TITLEID} ${PROJECT_NAME}.self
FILE ${CMAKE_SOURCE_DIR}/assets/cloud_server.png cloud_server.png FILE ${CMAKE_SOURCE_DIR}/assets/cloud_server.png cloud_server.png
FILE ${CMAKE_SOURCE_DIR}/assets/cloud_device.png cloud_device.png FILE ${CMAKE_SOURCE_DIR}/assets/cloud_device.png cloud_device.png
FILE ${CMAKE_SOURCE_DIR}/assets/cloud_both.png cloud_both.png FILE ${CMAKE_SOURCE_DIR}/assets/cloud_both.png cloud_both.png
FILE ${CMAKE_SOURCE_DIR}/assets/maptiles.pak maptiles.pak
) )
+36 -18
View File
@@ -1,28 +1,46 @@
FROM vitasdk/vitasdk:latest FROM vitasdk/vitasdk:latest
RUN apk add --no-cache \ # The published image has changed base distro over time (Alpine -> Ubuntu), so
cmake \ # probe for the package manager instead of assuming `apk`. Recent images
make \ # already ship cmake/make/git, in which case this layer is a no-op.
git \ RUN if command -v apk >/dev/null 2>&1; then \
ca-certificates apk add --no-cache cmake make git ca-certificates; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update && apt-get install -y --no-install-recommends \
cmake make git ca-certificates && rm -rf /var/lib/apt/lists/*; \
fi
# vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm # vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm; -f = force install.
# -f = force install (also works for first-time installs) #
RUN vdpm -f \ # NOTE 1: `openssl` (1.0.2) is deliberately NOT listed. The SDK's libcurl.a
zlib \ # (curl 8.17) is prebuilt against OpenSSL 1.0.2, but the image ships
libpng \ # openssl-1.1.1 as the installed default (a dependency of cpython3) and the two
libjpeg-turbo \ # vdpm packages conflict, so `vdpm openssl` aborts and leaves libcurl
freetype \ # referencing 1.0.2-only symbols (EVP_MD_CTX_create, SSLeay,
openssl \ # SSLv23_client_method, ...) that 1.1.1 does not provide -> link failure.
curl \ # The RUN below overlays the matching 1.0.2 libs/headers from the tarball.
zstd \ #
vita2d # NOTE 2: vita2d is NOT a vdpm package (it ships with the SDK core).
RUN yes | vdpm -f zlib libpng libjpeg-turbo freetype curl zstd
# Overlay the OpenSSL 1.0.2 build libcurl was compiled against (see above).
# This is the `openssl` vdpm package from the release snapshot the image's own
# package DB points at; the SHA256 is the one recorded in that DB.
ARG OPENSSL_URL=https://github.com/vitasdk/vitasdk-autobuild/releases/download/packages-2026.08-snapshot-20260925.50.1/openssl-1.0.2.r12304.gcd52124-1-vita.pkg.tar.xz
ADD ${OPENSSL_URL} /tmp/openssl102.tar.xz
RUN echo "1f97f3658fa6099354547202e2a9df6fa2919c60bcce314c83e99ef25de8998e /tmp/openssl102.tar.xz" | sha256sum -c - && \
mkdir -p /tmp/o102 && tar xJf /tmp/openssl102.tar.xz -C /tmp/o102 && \
cp -a /tmp/o102/arm-vita-eabi/lib/libcrypto.a \
/tmp/o102/arm-vita-eabi/lib/libssl.a \
/usr/local/vitasdk/arm-vita-eabi/lib/ && \
cp -a /tmp/o102/arm-vita-eabi/include/openssl/. \
/usr/local/vitasdk/arm-vita-eabi/include/openssl/ && \
rm -rf /tmp/o102 /tmp/openssl102.tar.xz
WORKDIR /src WORKDIR /src
COPY . . COPY . .
RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && \ RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && cmake --build /build --parallel 4
cmake --build /build --parallel "$(nproc)"
# Mount a host directory to /output to retrieve the VPK # Mount a host directory to /output to retrieve the VPK
CMD ["sh", "-c", "cp /build/vitaImmich.vpk /output/vitaImmich.vpk && echo 'Done: /output/vitaImmich.vpk'"] CMD ["sh", "-c", "cp /build/vitaImmich.vpk /output/vitaImmich.vpk && echo 'Done: /output/vitaImmich.vpk'"]
+120 -114
View File
@@ -1,151 +1,157 @@
# vitaImmich <div align="center">
Minimal proof-of-concept [Immich](https://immich.app/) client for the PS Vita. # 📷 vitaImmich
It connects to your Immich server and shows your library (photos and videos)
as a scrollable chronological grid (newest first), with a full-screen viewer
and hardware-accelerated video playback.
## Requirements ### Your self-hosted photo library, in your hands.
- A PS Vita with HENkaku/h-encore and VitaShell A native **[Immich](https://immich.app/)** client for the **PlayStation Vita** —
- An Immich server reachable from the Vita's Wi-Fi (recent Immich version — browse, search and map your whole photo & video library over Wi‑Fi, play videos
it uses `POST /api/search/random` and `GET /api/assets/{id}/thumbnail`) on the Vita's hardware decoder, and back up the camera roll, all on a 12‑year‑old
- An Immich API key (web UI → Account Settings → API Keys) handheld.
## Building </div>
Needs [VitaSDK](https://vitasdk.org/) with the `libvita2d`, `curl`, `openssl`, <table>
`zstd`, `zlib`, `libpng`, `libjpeg-turbo` and `freetype` packages installed <tr>
(via `vdpm`). <td width="33%"><img src="docs/timeline.png" alt="Timeline and smart search"></td>
<td width="33%"><img src="docs/places.png" alt="Geotag Places map"></td>
<td width="33%"><img src="docs/backup.png" alt="Server and backup overview"></td>
</tr>
<tr>
<td align="center"><b>Timeline &amp; search</b></td>
<td align="center"><b>Places map</b></td>
<td align="center"><b>Server &amp; backup</b></td>
</tr>
</table>
```sh ---
export VITASDK=$HOME/vitasdk
export PATH=$VITASDK/bin:$PATH
cmake -B build -S .
cmake --build build
```
This produces `build/vitaImmich.vpk`. ## ✨ Features
## Installing & configuring - 🗓️ **Timeline** — your entire library as a justified, date‑grouped grid (newest first), styled after the Immich web app.
- 🔍 **Smart search** — free‑text CLIP search straight from the Vita's keyboard.
- 🗺️ **Places map** — geotagged photos clustered on a world map; pan/zoom with the sticks and open any cluster as its own gallery.
- 🖼️ **Full‑screen viewer** — pinch/stick zoom & pan, swipe between photos, hardware‑accelerated **H.264 video** playback.
- ☁️ **Camera‑roll backup** — scans the Vita's photos & videos, dedupes against the server, and uploads on request.
- 📊 **Server overview** — storage usage, server/app versions, and per‑device backup status.
- 👆 **Touch + buttons** — the whole UI is drivable with the front touchscreen or the d‑pad/sticks.
1. Copy `vitaImmich.vpk` to the Vita and install it with VitaShell. > **Status:** proof‑of‑concept. App title id **`VIMM00001`**. See [limitations](#-limitations).
2. Launch it once — it creates `ux0:data/vitaimmich/config.txt`.
3. Edit that file (VitaShell → SELECT for FTP makes this easy):
``` ## 📦 Requirements
- A PS Vita running **HENkaku/h‑encore** with **VitaShell**.
- A reasonably recent **Immich server** reachable over the Vita's Wi‑Fi.
- An **Immich API key** (web UI → *Account Settings → API Keys*), or an email/password.
## 🚀 Install & configure
1. Grab `vitaImmich.vpk` from the [latest release](https://codeberg.org/SadsArches/vitaImmich/releases) (or build it — see below) and install it with **VitaShell**.
2. Launch it once; it creates `ux0:data/vitaimmich/config.txt`.
3. Edit that file (VitaShell's FTP, **SELECT**, makes this easy):
```ini
server=http://192.168.1.100:2283 server=http://192.168.1.100:2283
apikey=your-immich-api-key apikey=your-immich-api-key
``` ```
If your server sits behind a public domain (reverse proxy) and your Behind a reverse proxy without NAT loopback? Pin the domain to your LAN IP
router does not support NAT loopback, add the server's LAN IP so the so the Host header / TLS SNI stay on the domain but the connection goes
domain is pinned to it while on your home network: straight to the server:
``` ```ini
server=https://immich.example.com server=https://immich.example.com
apikey=your-immich-api-key apikey=your-immich-api-key
serverip=192.168.1.100 serverip=192.168.1.100
``` ```
This keeps the Host header and TLS SNI on the domain (so the reverse 4. Relaunch.
proxy still routes correctly) but connects to the LAN IP directly.
4. Relaunch the app. ## 🎮 Controls
## Controls **Timeline**
Grid view: | Button | Action |
| --- | --- |
| D‑pad / sticks | Move selection (hold to repeat) |
| L / R | Jump a month up / down |
| △ | Smart search |
| X | Open full screen |
| 🗺️ / ☁️ (touch) | Open the Places map / server page |
| SELECT | Sync overview |
| START | Exit |
| Button | Action | **Places map**
| ------------- | ------------------------------- |
| D-pad | Move selection (hold to repeat) |
| L / R | Jump two rows up / down |
| X | Open item full screen |
| SELECT | Open the sync overview |
| START | Exit |
Full-screen view: | Button | Action |
| --- | --- |
| Left stick / d‑pad / drag | Pan |
| Right stick / L · R | Zoom out / in |
| X | Open the centred cluster as a gallery |
| △ | Toggle the lat/lon graticule |
| O | Back |
| Button | Action | **Full‑screen viewer**
| ------------- | --------------------------------- |
| Left / Right | Previous / next item |
| X | Play video / retry a failed photo |
| O | Back to grid |
| SELECT | Open the sync overview |
| START | Exit |
Sync overview (SELECT): | Button | Action |
| --- | --- |
| ← / → | Previous / next |
| Right stick / L · R | Zoom · left stick pans |
| X | Play video / retry a failed load |
| O | Back |
| Button | Action | **Video playback** — X pause/resume · ←/→ seek ±10 s · O stop.
| ------------- | ----------------------------------- |
| X | Queue all local-only files to upload|
| O / SELECT | Back |
| START | Exit |
Video playback: ## 🔧 Building
| Button | Action | Needs [VitaSDK](https://vitasdk.org/) with `libvita2d`, `curl`, `openssl`,
| ------------- | ----------------- | `zstd`, `zlib`, `libpng`, `libjpeg-turbo` and `freetype` (install via `vdpm`).
| X | Pause / resume |
| Left / Right | Seek ±10 seconds |
| O | Stop and go back |
More of the library is fetched automatically (100 photos at a time, newest ```sh
first) as you scroll toward the end. Errors are shown on screen and logged export VITASDK=$HOME/vitasdk
to `ux0:data/vitaimmich/log.txt`. export PATH=$VITASDK/bin:$PATH
cmake -B build -S .
cmake --build build # -> build/vitaImmich.vpk
```
## Syncing the Vita's camera media The sources in `src/` are a **unity build**: `main.c` `#include`s the section
files (`state.c`, `draw.c`, … `app.c`) into one translation unit, so only
`main.c` is compiled. The geotag map's base tiles live in `assets/maptiles.pak`,
generated from a world‑map image by `tools/png_to_tiles.py`.
On startup the app mounts the Photos app's storage (`photo0:`, i.e. the ## ☁️ Camera‑roll backup
ACL-protected `ux0:picture` where camera shots and recordings live) via
`sceAppMgrAppDataMount`, scans it (plus `ux0:video/CAMERA`, a few levels
deep; `.jpg/.jpeg/.png/.mp4`) and merges those files into the same
date-sorted grid as your server library.
Files larger than 512 MB are skipped so a movie collection in `ux0:video`
is never hashed or offered for upload. Both knobs live in `config.txt`:
`syncdir=<folder>` (repeatable, replaces the default scan locations) and
`syncmaxmb=<MB>` (the size cap; 0 disables it).
A small badge in the top-left corner of each cell shows its status:
- blue-grey dot — **cloud only** (a server asset not present on the Vita) On startup the app mounts the Photos app's storage (`photo0:` /
- orange up-arrow — **local only** (on the Vita, not yet on the server) `ux0:picture`) plus `ux0:video/CAMERA`, scans for `.jpg/.jpeg/.png/.mp4`, and
- blinking orange — **queued / uploading** merges those files into the same date‑sorted timeline as your server library. A
- green check — **backed up** (exists on both; shown once, as the server asset) badge in each cell's corner shows status:
- red dot — **failed** (see the sync overview for the reason)
A background thread hashes each local file (SHA1) and asks the server which - 🔵 blue‑grey dot — **cloud only**
ones already exist (`POST /api/assets/bulk-upload-check`). Hashing and the - 🔶 orange up‑arrow — **local only**
duplicate check start automatically. Uploads do **not** start on their own: - 🟠 blinking — **queued / uploading**
open the sync overview with **SELECT** and press **X** to queue every - 🟢 green check — **backed up** (on both; shown once, as the server asset)
local-only file. Each upload is a `POST /api/assets` multipart request with - 🔴 red dot — **failed**
an `x-immich-checksum` header for fast server-side dedup; progress is shown
in the overview.
## PoC limitations A background thread SHA‑1‑hashes each local file and asks the server which
already exist (`bulk-upload-check`). Uploads never start on their own — open the
sync overview (**SELECT**) and press **X** to queue every local‑only file.
Tunables in `config.txt`: `syncdir=<folder>` (repeatable) and `syncmaxmb=<MB>`
(size cap; files over 512 MB are skipped by default, 0 disables).
- TLS certificate verification is disabled (no CA bundle is shipped), so ## ⚠️ Limitations
prefer plain HTTP on a trusted LAN or treat HTTPS as unverified.
- Thumbnails stream in on a background thread (one at a time over a - **TLS verification is disabled** (no CA bundle ships) — prefer plain HTTP on a
keep-alive connection); cells show a grey placeholder until loaded. trusted LAN, or treat HTTPS as unverified.
- Videos are downloaded in full to `ux0:data/vitaimmich/video.mp4` before - Videos download in full to the memory card before playing (deleted after).
playback (deleted afterwards), so they need free space on the memory card Playback is the Vita's hardware decoder via SceAvPlayer, so only **MP4
and a moment to start. Playback uses the Vita's hardware decoder via (H.264/AAC)** plays — Immich's transcoded `video/playback` stream is used.
SceAvPlayer, so only MP4 (H.264/AAC) plays — Immich's transcoded - Backup is **one‑way** (Vita → server); `deviceId` is hard‑coded to "PS Vita"
`video/playback` stream is used, which is H.264 with default server and uploads aren't added to an album.
settings. The decoder rotates four output frame buffers (~13 MB CDRAM at - Thumbnails stream in one at a time on a background thread (grey placeholder
1080p) so async GPU rendering can't be torn by a recycled frame. until loaded); hashing the camera roll fills statuses in gradually.
- Caps at 1000 server assets and 500 local camera files per launch; no
albums or search. ---
- Local media sync is one-way (Vita → server). It scans `ux0:picture` and
`ux0:video/CAMERA` by default (the standard camera locations), recursing <div align="center">
~2 levels; use `syncdir=` in the config to scan other folders. Made by <b>SadsArches</b> with love. ❤️
- Local video grid cells show a poster thumbnail (with the VIDEO badge): </div>
the first frame is decoded with SceAvPlayer on the background loader
thread (NV12 -> RGB, downscaled), the same handoff as photos. Poster
extraction and playback share the single hardware decoder, so a poster is
never decoded while a video is playing (and vice versa).
- Hashing 500 files at startup takes a while; it runs in the background so
browsing is never blocked, but statuses fill in gradually.
- `deviceId` is hard-coded to "PS Vita" and uploads are not associated with
an album.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

+18 -1
View File
@@ -2,7 +2,24 @@
Task list for the current round of work. Checked items are done. Task list for the current round of work. Checked items are done.
## -1. UI polish round 2 (current) ## -2. Albums (current)
- [x] Third top-bar button (photo-stack glyph) left of the map/cloud
buttons; d-pad focus cycles field -> albums -> map -> cloud, tap works.
- [x] Albums page: GET /api/albums, 4-column card grid (cover cropped to
4:3, name, item count, "Shared"), d-pad/tap, X open, O back,
triangle refresh. Covers load through the shared thumbnail worker
(cover id -> asset_for_id) and only rows near the viewport keep a
texture.
- [x] Opening an album fetches every member with /api/search/metadata
({"albumIds":[id]} paged by nextPage, nextCursor followed if sent)
on a worker thread with a progress throbber, then shows them on the
grid in month sections; the bar shows the album name and O (or the
red chip) returns to the album list.
- [x] Test-only scripted input (-DVIMM_TEST_INPUT, reads
ux0:/data/vitaimmich/test_input.txt) for Vita3K runs where synthetic
key events don't reach the emulator.
## -1. UI polish round 2
- [x] Reload images taken by the camera while the app was asleep: detect - [x] Reload images taken by the camera while the app was asleep: detect
resume via an RTC wall-clock jump (>3 s) and append-only re-scan the resume via an RTC wall-clock jump (>3 s) and append-only re-scan the
camera folders (headroom reserved at startup so no realloc races the camera folders (headroom reserved at startup so no realloc races the
+320
View File
@@ -0,0 +1,320 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* albums page */
/* */
/* Opened from the albums button on the top bar. A grid of album cards */
/* (cover, name, item count). Covers are ordinary server thumbnails: */
/* each album's cover id becomes a g_asset entry (asset_for_id) and the */
/* shared thumbnail worker loads it into g_thumb[]. Only rows near the */
/* viewport keep their cover texture (libraries can have hundreds of */
/* albums). Opening an album fills g_album_idx and the grid shows it */
/* like a search result set (see rebuild_display). */
/* ------------------------------------------------------------------ */
#define ALB_COLS 4
#define ALB_CW 210.0f /* card width */
#define ALB_COVH 158.0f /* cover height (4:3) */
#define ALB_GAP 20.0f
#define ALB_ROWH (ALB_COVH + 62.0f) /* cover + two text lines + gap */
#define ALB_TOP 64.0f /* below the page header */
#define ALB_X0 ((SCREEN_W - ALB_COLS * ALB_CW - (ALB_COLS - 1) * ALB_GAP) / 2)
#define ALB_GLYPH 44 /* album glyph bake size on this page */
enum { ALB_NONE, ALB_BACK, ALB_OPEN, ALB_REFRESH };
static int g_alb_sel; /* selected album */
static float g_alb_scroll, g_alb_target;
static int g_alb_touch, g_alb_dragged;
static float g_alb_tx, g_alb_ty, g_alb_t0y, g_alb_s0;
static float alb_content_h(void)
{
int rows = (g_album_len + ALB_COLS - 1) / ALB_COLS;
return ALB_TOP + rows * ALB_ROWH;
}
static void alb_card_xy(int a, float *x, float *y)
{
*x = ALB_X0 + (a % ALB_COLS) * (ALB_CW + ALB_GAP);
*y = ALB_TOP + (a / ALB_COLS) * ALB_ROWH;
}
/* `text` shortened with "..." so it fits `maxw` px at `scale` */
static void alb_fit(char *out, size_t outsz, const char *text, float scale,
int maxw)
{
snprintf(out, outsz, "%s", text);
if (text_width(scale, out) <= maxw)
return;
size_t n = strlen(out);
while (n > 1) {
n--;
while (n > 1 && ((unsigned char)out[n] & 0xC0) == 0x80)
n--; /* don't split a UTF-8 sequence */
snprintf(out + n, outsz - n, "...");
if (text_width(scale, out) <= maxw)
return;
}
}
/* give every cover texture back to the pool (leaving the page, or
* reloading the list); the grid reloads anything it needs */
static void albums_release_covers(void)
{
vita2d_wait_rendering_done();
for (int a = 0; a < g_album_len; a++) {
int idx = g_album_cover[a];
if (idx >= 0 && idx < g_asset_count && g_thumb[idx] &&
!(g_req_state != REQ_IDLE && g_req_idx == idx)) {
tex_release(g_thumb[idx]);
g_thumb[idx] = NULL;
}
}
}
/* thumbnail worker handoff for the visible covers: collect a finished load,
* evict covers far from the viewport, request the next missing one */
static void albums_pump_covers(int first, int last)
{
if (g_req_state == REQ_DONE) {
__sync_synchronize();
consume_worker_result();
}
/* rows more than one row outside the viewport drop their texture */
int keep0 = first - ALB_COLS, keep1 = last + ALB_COLS;
for (int a = 0; a < g_album_len; a++) {
if (a >= keep0 && a <= keep1)
continue;
int idx = g_album_cover[a];
if (idx >= 0 && g_thumb[idx] &&
!(g_req_state != REQ_IDLE && g_req_idx == idx)) {
tex_release(g_thumb[idx]);
g_thumb[idx] = NULL;
}
}
if (g_req_state != REQ_IDLE)
return;
for (int k = -1; k <= last - first; k++) {
int a = (k < 0) ? g_alb_sel : first + k; /* selection first */
if (a < 0 || a >= g_album_len || !g_album_thumb[a][0])
continue;
if (g_album_cover[a] < 0)
g_album_cover[a] = asset_for_id(g_album_thumb[a]);
int idx = g_album_cover[a];
if (idx < 0 || g_thumb[idx] || g_thumb_failed[idx])
continue;
g_req_idx = idx;
g_req_src = SRC_SERVER;
g_req_detail = 0;
g_req_is_video = 0;
snprintf(g_req_id, sizeof(g_req_id), "%s", g_asset_ids[idx]);
__sync_synchronize();
g_req_state = REQ_PENDING;
return;
}
}
static void draw_album_card(int a, float x, float y, int selected)
{
uint32_t card = RGBA8(38, 38, 42, 255);
int idx = g_album_cover[a];
vita2d_texture *tex = (idx >= 0 && idx < g_asset_count) ? g_thumb[idx] : NULL;
if (selected)
draw_sel_outline(x - 5, y - 5, ALB_CW + 10, ALB_COVH + 10);
vita2d_draw_rectangle(x, y, ALB_CW, ALB_COVH, card);
if (tex) {
/* crop to fill the 4:3 cover box */
float tw = vita2d_texture_get_width(tex);
float th = vita2d_texture_get_height(tex);
float s = fmaxf(ALB_CW / tw, ALB_COVH / th);
float sw = ALB_CW / s, sh = ALB_COVH / s;
vita2d_draw_texture_part_scale(tex, x, y, (tw - sw) / 2,
(th - sh) / 2, sw, sh, s, s);
} else {
int gs = ALB_GLYPH;
vita2d_texture *g = icon_tex(ICON_ALBUM, gs);
if (g)
vita2d_draw_texture_tint(g, x + (ALB_CW - gs) / 2,
y + (ALB_COVH - gs) / 2,
RGBA8(90, 90, 98, 255));
}
char line[96];
alb_fit(line, sizeof(line), g_album_name[a][0] ? g_album_name[a] :
"(untitled album)", 0.9f, (int)ALB_CW);
draw_text(x, y + ALB_COVH + 22, selected ? RGBA8(255, 255, 255, 255) :
RGBA8(225, 225, 230, 255), 0.9f, line);
char meta[64];
snprintf(meta, sizeof(meta), "%d item%s%s", g_album_nassets[a],
g_album_nassets[a] == 1 ? "" : "s",
g_album_shared[a] ? " \xC2\xB7 Shared" : "");
draw_text(x, y + ALB_COVH + 44, RGBA8(140, 140, 148, 255), 0.8f, meta);
}
/* one frame of the album list: input, cover loading and drawing. returns
* ALB_OPEN (open g_alb_sel), ALB_BACK, ALB_REFRESH or ALB_NONE. */
static int albums_page(unsigned int pressed, unsigned int nav)
{
int action = ALB_NONE;
int n = g_album_len;
if (g_alb_sel >= n)
g_alb_sel = n > 0 ? n - 1 : 0;
if (n > 0) {
if (nav & SCE_CTRL_RIGHT && g_alb_sel + 1 < n)
g_alb_sel++;
if (nav & SCE_CTRL_LEFT && g_alb_sel > 0)
g_alb_sel--;
if (nav & SCE_CTRL_DOWN)
g_alb_sel = g_alb_sel + ALB_COLS < n ? g_alb_sel + ALB_COLS :
((g_alb_sel / ALB_COLS) < (n - 1) / ALB_COLS ? n - 1 :
g_alb_sel);
if (nav & SCE_CTRL_UP && g_alb_sel >= ALB_COLS)
g_alb_sel -= ALB_COLS;
if (pressed & SCE_CTRL_CROSS)
action = ALB_OPEN;
}
if (pressed & SCE_CTRL_CIRCLE)
action = ALB_BACK;
if (pressed & SCE_CTRL_TRIANGLE)
action = ALB_REFRESH;
float maxs = alb_content_h() - SCREEN_H + 16;
if (maxs < 0)
maxs = 0;
/* front touch: drag scrolls, a tap opens the card under it */
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f, ty = td.report[0].y * 0.5f;
if (!g_alb_touch) {
g_alb_touch = 1;
g_alb_dragged = 0;
g_alb_t0y = ty;
g_alb_s0 = g_alb_scroll;
} else {
float dy = ty - g_alb_t0y;
if (!g_alb_dragged && (dy > 14.0f || dy < -14.0f))
g_alb_dragged = 1;
if (g_alb_dragged) {
float ns = g_alb_s0 - dy;
g_alb_scroll = g_alb_target =
ns < 0 ? 0 : (ns > maxs ? maxs : ns);
}
}
g_alb_tx = tx;
g_alb_ty = ty;
} else if (g_alb_touch) {
g_alb_touch = 0;
if (!g_alb_dragged && g_alb_ty < ALB_TOP - 8 && g_alb_tx < 140) {
action = ALB_BACK; /* tap on the header's back label */
} else if (!g_alb_dragged) {
float cy = g_alb_ty + g_alb_scroll - ALB_TOP;
int row = (int)(cy / ALB_ROWH);
float inrow = cy - row * ALB_ROWH;
float cx = g_alb_tx - ALB_X0;
int col = (int)(cx / (ALB_CW + ALB_GAP));
float incol = cx - col * (ALB_CW + ALB_GAP);
int a = row * ALB_COLS + col;
if (cy >= 0 && cx >= 0 && col < ALB_COLS &&
incol <= ALB_CW && inrow <= ALB_COVH + 50 &&
a < n) {
g_alb_sel = a;
action = ALB_OPEN;
}
}
}
/* keep the selected card on screen (d-pad), with an eased scroll */
if (!g_alb_touch) {
float sx, sy;
alb_card_xy(g_alb_sel, &sx, &sy);
if (sy - g_alb_target < ALB_TOP)
g_alb_target = sy - ALB_TOP;
if (sy + ALB_ROWH - g_alb_target > SCREEN_H)
g_alb_target = sy + ALB_ROWH - SCREEN_H;
if (g_alb_target < 0)
g_alb_target = 0;
if (g_alb_target > maxs)
g_alb_target = maxs;
g_alb_scroll += (g_alb_target - g_alb_scroll) * 0.3f;
if (fabsf(g_alb_target - g_alb_scroll) < 0.5f)
g_alb_scroll = g_alb_target;
}
int first_row = (int)((g_alb_scroll) / ALB_ROWH);
int last_row = (int)((g_alb_scroll + SCREEN_H - ALB_TOP) / ALB_ROWH);
int first = first_row * ALB_COLS;
int last = (last_row + 1) * ALB_COLS - 1;
if (last >= n)
last = n - 1;
albums_pump_covers(first, last);
vita2d_start_drawing();
vita2d_clear_screen();
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H, RGBA8(16, 16, 16, 255));
for (int a = first; a <= last && a < n; a++) {
float x, y;
alb_card_xy(a, &x, &y);
draw_album_card(a, x, y - g_alb_scroll, a == g_alb_sel);
}
if (n == 0)
draw_centered(SCREEN_H / 2, RGBA8(160, 160, 165, 255),
"No albums on this account");
/* header: back label, title, count (drawn last: cards scroll under it) */
vita2d_draw_rectangle(0, 0, SCREEN_W, ALB_TOP - 8, RGBA8(16, 16, 16, 255));
{
int gs = 24;
vita2d_texture *og = icon_tex(ICON_CIRCLE, gs);
if (og)
vita2d_draw_texture_tint(og, 18, 16, RGBA8(235, 90, 85, 255));
/* same bake size as the card placeholder: icon_tex caches one
* size per glyph, so mixing sizes in a frame re-bakes (and frees)
* the texture mid-frame */
vita2d_texture *ag = icon_tex(ICON_ALBUM, ALB_GLYPH);
if (ag)
vita2d_draw_texture_tint_scale(ag, 56, 14, 28.0f / ALB_GLYPH,
28.0f / ALB_GLYPH,
RGBA8(235, 235, 240, 255));
draw_text(94, 37, RGBA8(235, 235, 240, 255), 1.1f, "Albums");
char cnt[64];
snprintf(cnt, sizeof(cnt), "%d album%s X open /\\ refresh",
n, n == 1 ? "" : "s");
draw_text(SCREEN_W - 18 - text_width(0.8f, cnt), 36,
RGBA8(140, 140, 148, 255), 0.8f, cnt);
}
vita2d_end_drawing();
vita2d_swap_buffers();
return action;
}
/* worker threads (the main thread only animates a throbber meanwhile) */
static int albums_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
fetch_albums();
__sync_synchronize();
g_album_fetching = 0;
return 0;
}
static int album_open_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
int r = fetch_album_assets(g_album_open);
__sync_synchronize();
g_album_assets_result = r;
g_album_assets_done = 1;
return 0;
}
+670
View File
@@ -0,0 +1,670 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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 (exifInfo is the one nested
* object we descend into, for the photo's display aspect ratio) */
int added = 0;
int cur = -1, cur_obj = -1, exif_obj = -1;
int exw = 0, exh = 0, exori = 0;
for (int i = arr_idx + 1; i < ntok; i++) {
if (tok[i].type == JSMN_OBJECT && tok[i].parent == arr_idx) {
if (cur >= 0 && exw > 0 && exh > 0)
g_asset_ratio[cur] = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
if (cur >= 0)
g_asset_rot[cur] = (unsigned char)exori;
if (!grow_assets(g_asset_count + 1))
break; /* out of memory: keep what we have */
cur = g_asset_count++;
cur_obj = i;
exif_obj = -1;
exw = exh = exori = 0;
added++;
g_asset_ids[cur][0] = '\0';
g_asset_dates[cur][0] = '\0';
g_asset_ratio[cur] = 0.0f;
g_asset_rot[cur] = 0;
g_asset_is_video[cur] = 0;
g_asset_hidden[cur] = 0;
continue;
}
if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 ||
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 (tok[i].parent == exif_obj) {
/* numbers may also arrive as strings; atoi covers both
* and yields 0 on null */
if (klen == 14 && !strncmp(k, "exifImageWidth", 14))
exw = atoi(js + val->start);
else if (klen == 15 && !strncmp(k, "exifImageHeight", 15))
exh = atoi(js + val->start);
else if (klen == 11 && !strncmp(k, "orientation", 11))
exori = atoi(js + val->start);
continue;
}
if (tok[i].parent != cur_obj)
continue;
if (klen == 8 && !strncmp(k, "exifInfo", 8) &&
val->type == JSMN_OBJECT) {
exif_obj = i + 1;
continue;
}
if (val->type != JSMN_STRING)
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));
}
if (cur >= 0 && exw > 0 && exh > 0)
g_asset_ratio[cur] = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
if (cur >= 0)
g_asset_rot[cur] = (unsigned char)exori;
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 */
/* compact away just-parsed entries [before, g_asset_count) whose id already
* exists below `before` (the server's pages shift when assets are added or
* removed, so refetches can overlap). order among the new tail doesn't
* matter — the display rebuild sorts by date — and none of these have
* textures yet. returns how many genuinely new entries remain. */
static int dedup_new_assets(int before)
{
int n = g_asset_count;
for (int i = before; i < n; ) {
int dup = 0;
for (int j = 0; j < before && !dup; j++)
dup = !strcmp(g_asset_ids[j], g_asset_ids[i]);
if (!dup) {
i++;
continue;
}
n--;
if (i != n) {
memcpy(g_asset_ids[i], g_asset_ids[n],
sizeof(g_asset_ids[0]));
memcpy(g_asset_dates[i], g_asset_dates[n],
sizeof(g_asset_dates[0]));
g_asset_ratio[i] = g_asset_ratio[n];
g_asset_rot[i] = g_asset_rot[n];
g_asset_is_video[i] = g_asset_is_video[n];
}
}
g_asset_count = n;
return n - before;
}
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\",\"withExif\":true}",
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 before = g_asset_count;
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 (raw) page means we reached the end of the library; the
* dedup below only filters overlap from server-side page drift */
g_next_page = (added == PAGE_SIZE) ? g_next_page + 1 : 0;
int fresh = dedup_new_assets(before);
int with_ratio = 0;
for (int i = g_asset_count - fresh; i < g_asset_count; i++)
with_ratio += (g_asset_ratio[i] > 0.0f);
log_line("page fetched: +%d assets (total %d, %d with exif ratio)",
fresh, g_asset_count, with_ratio);
return fresh;
}
/* poll the newest page for photos added to the server since we fetched;
* duplicates of already-known assets are dropped, so only genuinely new
* items remain appended. main-thread only (mutates the asset arrays).
* returns the number of new assets. */
static int check_new_assets(void)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/search/metadata", g_server);
char body[160];
snprintf(body, sizeof(body),
"{\"page\":1,\"size\":%d,\"order\":\"desc\",\"withExif\":true}",
PAGE_SIZE);
membuf buf;
long code;
CURLcode res = http_request(url, body, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
free(buf.data);
return 0;
}
char err[160];
int before = g_asset_count;
int added = parse_assets(buf.data, buf.size, err, sizeof(err));
free(buf.data);
if (added <= 0)
return 0;
int fresh = dedup_new_assets(before);
if (fresh > 0)
log_line("poll: %d new asset(s) on the server", fresh);
return fresh;
}
/* parse a single /api/assets/{id} object for the fields the grid needs to lay
* an item out: capture date, display aspect (from exif), orientation, video
* flag. returns 1 if the date was found. (parse_assets handles arrays; this is
* the one-object form used by the gallery's lazy metadata resolver.) */
static int parse_one_asset(const char *js, size_t len, char *date, size_t datelen,
float *ratio, unsigned char *rot, int *is_video)
{
jsmn_parser p;
jsmn_init(&p);
int n = jsmn_parse(&p, js, len, NULL, 0);
if (n <= 0)
return 0;
jsmntok_t *t = malloc(sizeof(*t) * n);
if (!t)
return 0;
jsmn_init(&p);
n = jsmn_parse(&p, js, len, t, n);
if (n <= 0 || t[0].type != JSMN_OBJECT) {
free(t);
return 0;
}
date[0] = '\0';
*ratio = 0.0f;
*rot = 0;
*is_video = 0;
int exif_obj = -1, exw = 0, exh = 0, exori = 0, got = 0;
for (int i = 1; i + 1 < n; i++) {
if (t[i].type != JSMN_STRING || t[i].size != 1)
continue;
int kl = t[i].end - t[i].start;
const char *k = js + t[i].start;
jsmntok_t *v = &t[i + 1];
int vl = v->end - v->start;
if (t[i].parent == 0 && kl == 8 && !strncmp(k, "exifInfo", 8) &&
v->type == JSMN_OBJECT) {
exif_obj = i + 1;
continue;
}
if (t[i].parent == exif_obj) {
if (kl == 14 && !strncmp(k, "exifImageWidth", 14))
exw = atoi(js + v->start);
else if (kl == 15 && !strncmp(k, "exifImageHeight", 15))
exh = atoi(js + v->start);
else if (kl == 11 && !strncmp(k, "orientation", 11))
exori = atoi(js + v->start);
continue;
}
if (t[i].parent != 0)
continue;
if (kl == 13 && !strncmp(k, "fileCreatedAt", 13) && vl >= 10) {
int cl = vl < (int)datelen - 1 ? vl : (int)datelen - 1;
memcpy(date, js + v->start, cl);
date[cl] = '\0';
got = 1;
} else if (kl == 4 && !strncmp(k, "type", 4)) {
*is_video = (vl >= 5 && !strncmp(js + v->start, "VIDEO", 5));
}
}
if (exw > 0 && exh > 0)
*ratio = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
*rot = (unsigned char)exori;
free(t);
return got;
}
/* background metadata resolver thread: fetches one asset's date/aspect at a
* time (the gallery's lazy fill). One-slot handoff like the thumb worker —
* main writes g_meta_id + flips to PENDING, we fetch+parse and flip to DONE. */
static int meta_worker(SceSize args, void *argp)
{
(void)args;
(void)argp;
for (;;) {
if (g_meta_state != META_PENDING) {
sceKernelDelayThread(4000);
continue;
}
char url[600];
snprintf(url, sizeof(url), "%s/api/assets/%s", g_server, g_meta_id);
membuf buf;
long code;
CURLcode r = http_request(url, NULL, &buf, &code, NULL, 0);
g_meta_date[0] = '\0';
g_meta_ratio = 0.0f;
g_meta_rot = 0;
g_meta_is_video = 0;
if (r == CURLE_OK && code >= 200 && code < 300 && buf.data)
parse_one_asset(buf.data, buf.size, g_meta_date,
sizeof(g_meta_date), &g_meta_ratio,
&g_meta_rot, &g_meta_is_video);
free(buf.data);
__sync_synchronize();
g_meta_state = META_DONE;
}
return 0;
}
/* ------------------------------------------------------------------ */
/* albums (read-only) */
/* */
/* GET /api/albums returns a flat JSON array of AlbumResponseDto */
/* (id, albumName, albumThumbnailAssetId, assetCount, shared, ...). */
/* An album's photos come from POST /api/search/metadata with */
/* {"albumIds":[id],"page":N,"size":...,"withExif":true}. The plain */
/* albumIds form pages with assets.nextPage on every server version */
/* that has search/metadata; newer servers (3.x) may answer with */
/* assets.nextCursor instead, which is followed too. */
/* ------------------------------------------------------------------ */
#define ALBUM_PAGE_SIZE 250
#define ALBUM_MAX_PAGES 200 /* 50k photos: a runaway-loop guard */
/* raw text of `key` anywhere in the document (first match), "" for null.
* json_get only looks at top-level keys; paging info is nested in "assets". */
static int json_find_any(const char *js, size_t len, const char *key,
char *out, size_t outlen)
{
jsmn_parser p;
jsmn_init(&p);
int n = jsmn_parse(&p, js, len, NULL, 0);
if (n <= 0)
return 0;
jsmntok_t *t = malloc(sizeof(*t) * n);
if (!t)
return 0;
jsmn_init(&p);
n = jsmn_parse(&p, js, len, t, n);
int kl = (int)strlen(key), got = 0;
for (int i = 1; i + 1 < n; i++) {
if (t[i].type == JSMN_STRING && t[i].size == 1 &&
t[i].end - t[i].start == kl &&
!strncmp(js + t[i].start, key, kl)) {
int vl = t[i + 1].end - t[i + 1].start;
if (t[i + 1].type == JSMN_PRIMITIVE && vl == 4 &&
!strncmp(js + t[i + 1].start, "null", 4))
vl = 0;
if (vl >= (int)outlen)
vl = (int)outlen - 1;
memcpy(out, js + t[i + 1].start, vl);
out[vl] = '\0';
got = 1;
break;
}
}
free(t);
return got;
}
static int json_true(const char *js, const jsmntok_t *v)
{
return v->type == JSMN_PRIMITIVE && v->end - v->start == 4 &&
!strncmp(js + v->start, "true", 4);
}
/* parse a GET /api/albums response into the g_album_* arrays, preserving the
* server's ordering. returns the number of albums parsed, or -1 on error. */
static int parse_albums(const char *js, size_t len)
{
jsmn_parser p;
jsmn_init(&p);
int nt = jsmn_parse(&p, js, len, NULL, 0);
if (nt <= 0)
return -1;
jsmntok_t *t = malloc(sizeof(*t) * nt);
if (!t)
return -1;
jsmn_init(&p);
nt = jsmn_parse(&p, js, len, t, nt);
if (nt <= 0 || t[0].type != JSMN_ARRAY) {
free(t);
return -1;
}
g_album_len = 0;
int cur = -1, cur_obj = -1;
for (int i = 1; i < nt; i++) {
if (t[i].type == JSMN_OBJECT && t[i].parent == 0) {
if (!grow_albums(g_album_len + 1))
break; /* out of memory: keep what we have */
cur = g_album_len++;
cur_obj = i;
g_album_id[cur][0] = '\0';
g_album_name[cur][0] = '\0';
g_album_thumb[cur][0] = '\0';
g_album_nassets[cur] = 0;
g_album_shared[cur] = 0;
g_album_has_link[cur] = 0;
g_album_cover[cur] = -1;
continue;
}
if (cur < 0 || t[i].type != JSMN_STRING || t[i].size != 1 ||
i + 1 >= nt || t[i].parent != cur_obj)
continue;
int kl = t[i].end - t[i].start;
const char *k = js + t[i].start;
jsmntok_t *v = &t[i + 1];
int vl = v->end - v->start;
if (kl == 2 && !strncmp(k, "id", 2) && v->type == JSMN_STRING &&
vl > 0 && vl < (int)sizeof(g_album_id[0]))
snprintf(g_album_id[cur], sizeof(g_album_id[0]), "%.*s",
vl, js + v->start);
else if (kl == 9 && !strncmp(k, "albumName", 9) &&
v->type == JSMN_STRING)
/* names may be cut mid UTF-8 sequence; draw_text copes */
snprintf(g_album_name[cur], sizeof(g_album_name[0]),
"%.*s", vl, js + v->start);
else if (kl == 21 && !strncmp(k, "albumThumbnailAssetId", 21) &&
v->type == JSMN_STRING && vl > 0 &&
vl < (int)sizeof(g_album_thumb[0]))
/* an empty album's null cover is a JSMN_PRIMITIVE */
snprintf(g_album_thumb[cur], sizeof(g_album_thumb[0]),
"%.*s", vl, js + v->start);
else if (kl == 10 && !strncmp(k, "assetCount", 10))
g_album_nassets[cur] = atoi(js + v->start);
else if (kl == 6 && !strncmp(k, "shared", 6))
g_album_shared[cur] = (unsigned char)json_true(js, v);
else if (kl == 13 && !strncmp(k, "hasSharedLink", 13))
g_album_has_link[cur] = (unsigned char)json_true(js, v);
}
free(t);
/* drop entries without an id (would break the member fetch) */
int w = 0;
for (int r = 0; r < g_album_len; r++) {
if (!g_album_id[r][0])
continue;
if (w != r) {
memcpy(g_album_id[w], g_album_id[r], sizeof(g_album_id[0]));
memcpy(g_album_name[w], g_album_name[r], sizeof(g_album_name[0]));
memcpy(g_album_thumb[w], g_album_thumb[r], sizeof(g_album_thumb[0]));
g_album_nassets[w] = g_album_nassets[r];
g_album_shared[w] = g_album_shared[r];
g_album_has_link[w] = g_album_has_link[r];
g_album_cover[w] = g_album_cover[r];
}
w++;
}
g_album_len = w;
return g_album_len;
}
/* fetch the album list (GET /api/albums) into the g_album_* arrays.
* Sets g_album_state to 1 on success / -1 on failure. Worker thread. */
static void fetch_albums(void)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/albums", g_server);
membuf buf;
long code;
CURLcode res = http_request(url, NULL, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("albums: curl %d http %ld: %.200s", res, code,
buf.data ? buf.data : "(no body)");
free(buf.data);
__sync_synchronize();
g_album_state = -1;
return;
}
int n = parse_albums(buf.data, buf.size);
size_t sz = buf.size;
free(buf.data);
if (n < 0) {
log_line("albums: JSON parse failed (%d bytes)", (int)sz);
__sync_synchronize();
g_album_state = -1;
return;
}
log_line("albums: %d fetched", n);
__sync_synchronize();
g_album_state = 1;
}
/* fetch every member of album `a` into g_album_idx (server assets; new ones
* are appended to g_asset_* like smart-search results). Worker thread: the
* main thread only draws a throbber while this runs (same contract as
* run_smart_search). Returns the member count, or -1 on failure. */
static int fetch_album_assets(int a)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/search/metadata", g_server);
g_album_idx_count = 0;
g_album_assets_loaded = 0;
static char (*ids)[40];
static int ids_cap;
int page = 1;
char cursor[160] = "";
for (int guard = 0; guard < ALBUM_MAX_PAGES; guard++) {
char body[420];
if (cursor[0])
snprintf(body, sizeof(body),
"{\"albumIds\":[\"%s\"],\"size\":%d,"
"\"cursor\":\"%s\",\"withExif\":true}",
g_album_id[a], ALBUM_PAGE_SIZE, cursor);
else
snprintf(body, sizeof(body),
"{\"albumIds\":[\"%s\"],\"size\":%d,"
"\"page\":%d,\"withExif\":true}",
g_album_id[a], ALBUM_PAGE_SIZE, page);
membuf buf;
long code;
CURLcode res = http_request(url, body, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("album '%s' page %d: curl %d http %ld: %.200s",
g_album_name[a], guard + 1, res, code,
buf.data ? buf.data : "(no body)");
free(buf.data);
return g_album_idx_count > 0 ? g_album_idx_count : -1;
}
char next_page[16] = "", next_cursor[160] = "";
json_find_any(buf.data, buf.size, "nextPage", next_page,
sizeof(next_page));
json_find_any(buf.data, buf.size, "nextCursor", next_cursor,
sizeof(next_cursor));
int before = g_asset_count;
char err[160];
int added = parse_assets(buf.data, buf.size, err, sizeof(err));
free(buf.data);
if (added < 0) {
log_line("album '%s' page %d: parse failed: %s",
g_album_name[a], guard + 1, err);
return g_album_idx_count > 0 ? g_album_idx_count : -1;
}
/* capture this page's ids before dedup compacts the tail, then
* resolve each one to its surviving g_asset index */
int raw = g_asset_count - before;
if (raw > ids_cap) {
void *np = realloc(ids, sizeof(ids[0]) * raw);
if (!np)
return -1;
ids = np;
ids_cap = raw;
}
for (int i = 0; i < raw; i++)
memcpy(ids[i], g_asset_ids[before + i], sizeof(ids[0]));
/* an id already present as a bare placeholder (an album cover or
* map-gallery entry from asset_for_id: no date/aspect yet) keeps
* its index through dedup, so hand it this page's metadata */
for (int i = before; i < g_asset_count; i++)
for (int j = 0; j < before; j++)
if (g_asset_dates[j][0] == '\0' &&
!strcmp(g_asset_ids[j], g_asset_ids[i])) {
memcpy(g_asset_dates[j], g_asset_dates[i],
sizeof(g_asset_dates[0]));
g_asset_ratio[j] = g_asset_ratio[i];
g_asset_rot[j] = g_asset_rot[i];
g_asset_is_video[j] = g_asset_is_video[i];
break;
}
dedup_new_assets(before);
for (int i = 0; i < raw; i++) {
int idx = -1;
for (int j = g_asset_count - 1; j >= 0; j--)
if (!strcmp(g_asset_ids[j], ids[i])) {
idx = j;
break;
}
if (idx < 0 || !grow_album_idx(g_album_idx_count + 1))
continue;
g_album_idx[g_album_idx_count++] = idx;
}
g_album_assets_loaded = g_album_idx_count;
if (next_cursor[0] && next_cursor[0] != '"') {
/* raw token text: strip nothing (jsmn excludes quotes) */
snprintf(cursor, sizeof(cursor), "%s", next_cursor);
} else if (next_page[0] && atoi(next_page) > page) {
page = atoi(next_page);
cursor[0] = '\0';
} else {
break; /* last page */
}
if (raw == 0)
break;
}
log_line("album '%s': %d member(s)", g_album_name[a], g_album_idx_count);
return g_album_idx_count;
}
+2339
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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=https://demo.immich.app\n"
"# auth: an API key, OR an email + password login.\n"
"apikey=\n"
"email=demo@immich.app\n"
"password=demo\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;
}
/* Manual line split instead of fgets(): the Vita3K *web* (wasm interpreter) build
* mis-scans fgets's newline and returns the whole file as one line, so server=
* captured every following line too. Read the file whole and split on '\n' by hand
* (fread + a plain char loop avoid the broken libc path). Native Vita is unaffected. */
char fbuf[4096];
size_t flen = fread(fbuf, 1, sizeof(fbuf) - 1, f);
fbuf[flen] = '\0';
char *lp = fbuf;
while (*lp) {
char *eol = lp;
while (*eol && *eol != '\n')
eol++;
char eolsave = *eol;
*eol = '\0';
char *s = clean_line(lp);
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, "email=", 6))
snprintf(g_email, sizeof(g_email), "%s", clean_line(s + 6));
else if (!strncmp(s, "password=", 9))
snprintf(g_password, sizeof(g_password), "%s", clean_line(s + 9));
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));
if (!eolsave)
break;
lp = eol + 1;
}
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';
/* need a server and either an API key or an email+password login */
if (strstr(g_apikey, "PASTE_YOUR"))
g_apikey[0] = '\0';
int have_key = g_apikey[0] != '\0';
int have_login = g_email[0] && g_password[0];
if (!g_server[0] || (!have_key && !have_login))
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;
}
+441
View File
@@ -0,0 +1,441 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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);
}
/* display aspect ratio (w/h) of a slot; local camera media and assets the
* server reported no exif for fall back to 4:3 */
static float disp_ratio(int d)
{
struct disp_item *it = &g_disp[d];
float r = (it->src == SRC_SERVER) ? g_asset_ratio[it->idx] : 0.0f;
return r > 0.0f ? r : 4.0f / 3.0f;
}
/* place row items [start, end) at *y. when justifying, the whole row is
* scaled so it spans the full screen width (the row height grows with it,
* capped so a sparse row can't blow up); a month's trailing partial row
* stays at its natural size, left-aligned. */
static void layout_finish_row(int start, int end, float *y, float natw,
int justify)
{
float scale = 1.0f;
if (justify && natw > 0.0f) {
scale = (float)SCREEN_W / natw;
if (scale > 1.55f)
scale = 1.55f;
}
float h = ROW_H * scale;
float x = 0.0f;
for (int i = start; i < end; i++) {
g_item_x[i] = x;
g_item_y[i] = *y;
g_item_w[i] *= scale;
g_item_h[i] = h;
x += g_item_w[i];
}
*y += h;
}
/* compute each display slot's grid cell + the month/year header bands.
* justified rows like the Immich web timeline: each item's width follows
* its aspect ratio, a row wraps when the next item no longer fits, and the
* closed row is stretched to fill the full screen width. the timeline is
* date-desc sorted, so a run of equal "YYYY-MM" is one month; every month
* starts on a fresh row under its own header. */
static void layout_grid(void)
{
g_sect_count = 0;
/* start below the pinned search bar so the first month header clears
* it at the top of the scroll range */
float y = SEARCH_H, natw = 0.0f;
int row_start = 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 (d > row_start) /* month's partial last row */
layout_finish_row(row_start, d, &y, natw, 0);
row_start = d;
natw = 0.0f;
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);
}
float w = ROW_H * disp_ratio(d);
if (w < ROW_H * 0.4f)
w = ROW_H * 0.4f; /* keep extreme portraits tappable */
if (w > SCREEN_W)
w = SCREEN_W; /* panoramas: one per row */
if (natw > 0.0f && natw + w > SCREEN_W) {
layout_finish_row(row_start, d, &y, natw, 1);
row_start = d;
natw = 0.0f;
}
g_item_w[d] = w; /* natural width; scaled when the row closes */
natw += w;
}
if (g_disp_count > row_start)
layout_finish_row(row_start, g_disp_count, &y, natw, 0);
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)
{
/* map cluster gallery: show the selected bubble's members (server assets),
* date-grouped like the timeline. A member is placed only once its
* date/aspect have resolved, so it drops straight into its final spot
* rather than appearing as an "unknown" placeholder that reflows the whole
* grid when its metadata later arrives. The rest stream in as they load. */
if (g_gallery_active) {
if (!grow_disp(g_gallery_count > 0 ? g_gallery_count : 1))
return;
int n = 0;
for (int k = 0; k < g_gallery_count; k++) {
int idx = g_gallery_idx[k];
if (g_asset_dates[idx][0] == '\0')
continue; /* metadata not in yet: not placeable */
g_disp[n].src = SRC_SERVER;
g_disp[n].idx = idx;
n++;
}
g_disp_count = n;
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
layout_grid();
return;
}
/* album mode: the open album's members (server assets), date-grouped
* like search results; placeholders without a date are skipped */
if (g_album_active) {
if (!grow_disp(g_album_idx_count > 0 ? g_album_idx_count : 1))
return;
int n = 0;
for (int k = 0; k < g_album_idx_count; k++) {
int idx = g_album_idx[k];
if (g_asset_dates[idx][0] == '\0')
continue;
g_disp[n].src = SRC_SERVER;
g_disp[n].idx = idx;
n++;
}
g_disp_count = n;
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
layout_grid();
return;
}
/* search mode: the merged timeline is replaced by the result set
* (server assets only, date-grouped); local media isn't folded in */
if (g_search_active) {
if (!grow_disp(g_search_count > 0 ? g_search_count : 1))
return;
int n = 0;
for (int k = 0; k < g_search_count; k++) {
g_disp[n].src = SRC_SERVER;
g_disp[n].idx = g_search_idx[k];
n++;
}
g_disp_count = n;
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
layout_grid();
return;
}
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++) {
if (g_asset_hidden[i])
continue; /* gallery-only asset: not in the timeline */
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();
}
static int find_disp(unsigned char src, int idx);
/* rebuild the display after the asset set changed, keeping the view glued
* to the photo the selection is on: the selection follows the item, and the
* scroll/target keep their offset relative to it, so a page fetch or poll
* relayout doesn't visibly move the grid. */
static void rebuild_keep_view(int *sel, float *scroll, float *target)
{
int had = (*sel >= 0 && *sel < g_disp_count);
struct disp_item keep = had ? g_disp[*sel] : (struct disp_item){ 0, 0 };
float dt = had ? *target - g_item_y[*sel] : 0.0f;
float ds = had ? *scroll - g_item_y[*sel] : 0.0f;
rebuild_display();
if (had) {
int ns = find_disp(keep.src, keep.idx);
if (ns >= 0) {
*sel = ns;
*target = g_item_y[ns] + dt;
*scroll = g_item_y[ns] + ds;
}
}
if (*sel >= g_disp_count)
*sel = g_disp_count - 1;
if (*sel < 0)
*sel = 0;
}
/* 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;
}
/* display slot whose grid cell contains the (screen-x, world-y) point,
* or -1: touch hit-testing for tap-to-open */
static int item_at(float x, float wy)
{
for (int i = 0; i < g_disp_count; i++) {
if (g_item_y[i] > wy)
break; /* item y is non-decreasing */
if (wy < g_item_y[i] + g_item_h[i] &&
x >= g_item_x[i] && x < g_item_x[i] + g_item_w[i])
return i;
}
return -1;
}
/* move the selection one row up (dir<0) or down (dir>0), landing on the
* item whose cell is horizontally nearest — rows hold a variable number
* of items now, so the fixed columns arithmetic no longer applies */
static int nav_row(int sel, int dir)
{
if (sel < 0 || sel >= g_disp_count)
return sel;
float cy = g_item_y[sel];
float cx = g_item_x[sel] + g_item_w[sel] / 2.0f;
int i = sel;
/* step off the current row */
while (i + dir >= 0 && i + dir < g_disp_count &&
g_item_y[i] == cy)
i += dir;
if (g_item_y[i] == cy)
return sel; /* already on the first/last row */
/* pick the horizontally nearest item of that row */
float ry = g_item_y[i];
int best = i;
float bestd = -1.0f;
for (; i >= 0 && i < g_disp_count && g_item_y[i] == ry; i += dir) {
float d = g_item_x[i] + g_item_w[i] / 2.0f - cx;
if (d < 0)
d = -d;
if (bestd < 0 || d < bestd) {
bestd = d;
best = i;
}
}
return best;
}
/* display slot nearest a world-y (used to keep the selection inside the
* viewport while a touch drag/fling moves the grid) */
static int item_near(float wy)
{
int last = -1;
for (int i = 0; i < g_disp_count; i++) {
if (g_item_y[i] + g_item_h[i] > wy)
return i;
last = i;
}
return last;
}
/* 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;
}
/* hand the worker a thumbnail request for display slot d (g_req_state must
* be REQ_IDLE); local videos get their poster extracted */
static void req_issue_thumb(int d)
{
struct disp_item *ti = &g_disp[d];
g_req_idx = ti->idx;
g_req_src = ti->src;
g_req_detail = 0;
if (ti->src == SRC_LOCAL) {
snprintf(g_req_path, sizeof(g_req_path), "%s",
g_local_path[ti->idx]);
g_req_is_video = g_local_is_video[ti->idx];
} else {
snprintf(g_req_id, sizeof(g_req_id), "%s",
g_asset_ids[ti->idx]);
g_req_is_video = 0;
}
__sync_synchronize();
g_req_state = REQ_PENDING;
}
+195
View File
@@ -0,0 +1,195 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* drawing helpers */
/* ------------------------------------------------------------------ */
/* one Overpass instance per pixel size: vita2d's glyph atlas caches every
* glyph at the first size it is drawn at and reuses that bitmap for other
* sizes, so mixing sizes in one font renders letters at the wrong size (an
* "E" cached at 20 px showing up oversized inside 17 px text). */
static vita2d_font *ttf_for(unsigned int px)
{
static vita2d_font *cache[8];
static unsigned int cache_px[8];
static int n;
for (int i = 0; i < n; i++)
if (cache_px[i] == px)
return cache[i];
if (n < 8) {
vita2d_font *f = vita2d_load_font_file("app0:font.ttf");
if (f) {
cache[n] = f;
cache_px[n] = px;
n++;
return f;
}
}
return g_ttf; /* shared instance, better than nothing */
}
/* text via the bundled Overpass TTF (Immich's typeface), falling back to
* the system PGF font if the TTF failed to load. the pgf "scale" the call
* sites use maps to a pixel size (1.0 ~ 20 px). */
static void draw_text(float x, float y, uint32_t color, float scale,
const char *text)
{
if (g_ttf) {
unsigned int px = (unsigned int)(scale * 20.0f + 0.5f);
vita2d_font_draw_text(ttf_for(px), (int)x, (int)y, color,
px, text);
} else {
vita2d_pgf_draw_text(g_font, x, y, color, scale, text);
}
}
static int text_width(float scale, const char *text)
{
if (g_ttf) {
unsigned int px = (unsigned int)(scale * 20.0f + 0.5f);
return vita2d_font_text_width(ttf_for(px), px, text);
}
return vita2d_pgf_text_width(g_font, scale, text);
}
static void draw_centered(int y, uint32_t color, const char *text)
{
int w = text_width(1.0f, text);
draw_text((SCREEN_W - w) / 2, y, color, 1.0f, text);
}
/* draw `text` centered and word-wrapped so no line exceeds maxw pixels,
* stacking lines line_h px apart. honours explicit '\n' as a hard break.
* returns the y just past the last line drawn. used by the error screens,
* whose messages used to be one centered line that ran off both edges. */
static int draw_centered_wrapped(int y, uint32_t color, const char *text,
int maxw, int line_h)
{
char line[256];
line[0] = '\0';
const char *p = text;
for (;;) {
while (*p == ' ') /* collapse runs of spaces between words */
p++;
const char *w = p;
while (*p && *p != ' ' && *p != '\n')
p++;
int wlen = (int)(p - w);
int forced_nl = (*p == '\n');
int end = (*p == '\0');
if (wlen > 0) {
char cand[256];
if (line[0])
snprintf(cand, sizeof(cand), "%s %.*s", line, wlen, w);
else
snprintf(cand, sizeof(cand), "%.*s", wlen, w);
/* place the word if it fits, or if the line is empty (a
* single over-long word goes on its own line rather than
* looping forever) */
if (!line[0] || text_width(1.0f, cand) <= maxw) {
snprintf(line, sizeof(line), "%s", cand);
} else {
draw_centered(y, color, line);
y += line_h;
snprintf(line, sizeof(line), "%.*s", wlen, w);
}
}
if (forced_nl) {
draw_centered(y, color, line);
y += line_h;
line[0] = '\0';
p++; /* consume the '\n' */
}
if (end) {
if (line[0]) {
draw_centered(y, color, line);
y += line_h;
}
return y;
}
}
}
/* startup loading screen: the Immich logo spinning, with text below it */
static void draw_loading(const char *text, unsigned int frame)
{
vita2d_start_drawing();
vita2d_clear_screen();
float cx = SCREEN_W / 2.0f, cy = SCREEN_H / 2.0f - 30.0f;
if (g_logo) {
float lw = vita2d_texture_get_width(g_logo);
float lh = vita2d_texture_get_height(g_logo);
/* draw smaller than the source so the GPU minifies it (smooth
* edges) and spin slowly */
float scale = 96.0f / lw;
vita2d_draw_texture_scale_rotate_hotspot(g_logo, cx, cy,
scale, scale, frame * 0.045f, lw / 2.0f, lh / 2.0f);
}
int w = text_width(1.0f, text);
draw_text((SCREEN_W - w) / 2, cy + 90.0f,
RGBA8(225, 225, 230, 255), 1.0f, text);
vita2d_end_drawing();
vita2d_swap_buffers();
}
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);
draw_text(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);
}
+95
View File
@@ -0,0 +1,95 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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;
}
+22 -5876
View File
File diff suppressed because it is too large Load Diff
+811
View File
@@ -0,0 +1,811 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* map / "places" view */
/* */
/* Browse geotagged photos on a world map. Markers come from Immich's */
/* GET /api/map/markers (a flat {id,lat,lon} list). There is no raster */
/* base map: markers are projected with Web Mercator onto a dark */
/* canvas with a faint lat/lon graticule, and nearby ones are grouped */
/* into count "bubbles" (re-clustered every frame in screen space, so */
/* zooming in splits a bubble apart). Pan with the d-pad / touch drag, */
/* zoom with L/R / pinch, X acts on the bubble nearest the centre */
/* (zoom into a cluster, or open a lone photo), O returns to the grid. */
/* ------------------------------------------------------------------ */
/* marker arrays, grown (doubling) by the fetch; read by the main thread
* only once g_map_state flips to 1 (so a half-parsed list never draws) */
static double *g_map_lat;
static double *g_map_lon;
static char (*g_map_id)[40];
static int g_map_count;
static int g_map_cap;
static volatile int g_map_state; /* 0 not fetched, 1 ok, -1 failed */
static volatile int g_map_fetching; /* a worker thread is fetching now */
/* view: Web-Mercator centre in normalised [0,1] coords and the world's
* on-screen pixel size (256 << zoom). g_map_view_init is cleared so the
* first time the page opens (and after an account switch) it fits to the
* markers' bounding box instead of showing a stale view. */
/* zoom limits — g_map_scale is the whole world's on-screen width in px.
* MIN stops zoom-out once the map covers the screen with no black bars; the map
* is taller-aspect than the screen, so the poles and a little longitude crop off
* (pan to reach them) rather than leaving margins. MAX is about country/region
* level — ~4x the deepest tile LOD (10496px), so the raster base map stays
* legible rather than blurring into mush as the old deep caps did. */
#define MAP_SCALE_MIN 1200.0
#define MAP_SCALE_MAX 4.0e4
/* the base map's ocean colour — fills the view behind/around the tiles so the
* margins (and any gap while tiles stream in) read as sea, not black */
#define MAP_OCEAN_BG RGBA8(217, 187, 149, 255)
static double g_map_cx = 0.5, g_map_cy = 0.5;
static double g_map_scale = MAP_SCALE_MIN;
static int g_map_view_init;
static int g_map_grid = 0; /* overlay the computed graticule (toggle: TRIANGLE) */
#define MAP_PI 3.14159265358979323846
/* The source map (a mapchart.net world map, city-label variant) is a
* near-conformal full-longitude Web Mercator: lon spans the image width,
* latitude band MAP_IMG_MY0..MY1. These bounds were fit by least-squares against
* the pixel-identical graticule export of the same map (meridian residual
* <0.5px, parallel <2.3px over 7000px) — that sibling shares this one's exact
* crop (verified: 0px phase-correlation shift), so the fit transfers directly.
* The image runs ~0.45° past ±180 each side. */
#define MAP_LON0 (-180.40) /* longitude at the image's left edge */
#define MAP_LON1 (180.50) /* longitude at the image's right edge */
/* lon/lat -> normalised image coords [0,1]. y is standard Web-Mercator (lat
* clamped to the valid band); the image's merc-y bounds are MAP_IMG_MY0/MY1. */
static double merc_x(double lon)
{
return (lon - MAP_LON0) / (MAP_LON1 - MAP_LON0);
}
static double merc_y(double lat)
{
if (lat > 85.05113) lat = 85.05113;
if (lat < -85.05113) lat = -85.05113;
double s = sin(lat * MAP_PI / 180.0);
return 0.5 - log((1.0 + s) / (1.0 - s)) / (4.0 * MAP_PI);
}
/* normalised merc coords -> screen pixels under the current view */
static void map_project(double mx, double my, float *sx, float *sy)
{
*sx = (float)((mx - g_map_cx) * g_map_scale) + SCREEN_W / 2.0f;
*sy = (float)((my - g_map_cy) * g_map_scale) + SCREEN_H / 2.0f;
}
static int grow_map(int need)
{
int c = g_map_cap;
if (need <= c)
return 1;
if (!GROW(g_map_lat, c, need) ||
!GROW(g_map_lon, c, need) ||
!(g_map_cap = GROW(g_map_id, c, need)))
return 0;
return 1;
}
/* parse a /api/map/markers response: a flat JSON array of objects, each
* with a string "id" and numeric "lat"/"lon". returns the marker count,
* or -1 on a parse error. (other fields — city/state/country — ignored.) */
static int parse_map_markers(const char *js, size_t len)
{
jsmn_parser p;
jsmn_init(&p);
int nt = jsmn_parse(&p, js, len, NULL, 0);
if (nt <= 0)
return -1;
jsmntok_t *t = malloc(sizeof(*t) * nt);
if (!t)
return -1;
jsmn_init(&p);
nt = jsmn_parse(&p, js, len, t, nt);
if (nt <= 0 || t[0].type != JSMN_ARRAY) {
free(t);
return -1;
}
g_map_count = 0;
int cur = -1, cur_obj = -1;
for (int i = 1; i < nt; i++) {
if (t[i].type == JSMN_OBJECT && t[i].parent == 0) {
if (!grow_map(g_map_count + 1))
break; /* out of memory: keep what we have */
cur = g_map_count++;
cur_obj = i;
g_map_id[cur][0] = '\0';
g_map_lat[cur] = 0.0;
g_map_lon[cur] = 0.0;
continue;
}
if (cur < 0 || t[i].type != JSMN_STRING || t[i].size != 1 ||
i + 1 >= nt || t[i].parent != cur_obj)
continue;
int kl = t[i].end - t[i].start;
const char *k = js + t[i].start;
jsmntok_t *v = &t[i + 1];
int vl = v->end - v->start;
if (kl == 2 && !strncmp(k, "id", 2) && vl > 0 &&
vl < (int)sizeof(g_map_id[0]))
snprintf(g_map_id[cur], sizeof(g_map_id[0]), "%.*s",
vl, js + v->start);
else if (kl == 3 && !strncmp(k, "lat", 3))
g_map_lat[cur] = atof(js + v->start);
else if (kl == 3 && !strncmp(k, "lon", 3))
g_map_lon[cur] = atof(js + v->start);
}
free(t);
/* drop trailing entries that somehow lack an id */
while (g_map_count > 0 && g_map_id[g_map_count - 1][0] == '\0')
g_map_count--;
return g_map_count;
}
/* fetch all geotagged markers (GET /api/map/markers). sets g_map_state to
* 1 on success / -1 on failure; runs on a worker thread so the page can
* animate a throbber while the request is in flight. */
static void fetch_map_markers(void)
{
char url[600];
snprintf(url, sizeof(url), "%s/api/map/markers", g_server);
membuf buf;
long code;
CURLcode res = http_request(url, NULL, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("map markers: curl %d http %ld: %.160s", res, code,
buf.data ? buf.data : "");
free(buf.data);
__sync_synchronize();
g_map_state = -1;
return;
}
int n = parse_map_markers(buf.data, buf.size);
free(buf.data);
if (n < 0) {
log_line("map markers: parse failed");
__sync_synchronize();
g_map_state = -1;
return;
}
log_line("map markers: %d geotagged", n);
__sync_synchronize();
g_map_state = 1;
}
/* keep the centre on the map and the zoom within sane bounds */
static void map_clamp_view(void)
{
if (g_map_scale < MAP_SCALE_MIN) g_map_scale = MAP_SCALE_MIN; /* whole world */
if (g_map_scale > MAP_SCALE_MAX) g_map_scale = MAP_SCALE_MAX; /* neighbourhood */
if (g_map_cx < 0.0) g_map_cx = 0.0;
if (g_map_cx > 1.0) g_map_cx = 1.0;
if (g_map_cy < 0.0) g_map_cy = 0.0;
if (g_map_cy > 1.0) g_map_cy = 1.0;
}
/* frame the markers' bounding box (with margin), or the whole world if there
* are none / they share a point */
static void map_fit_view(void)
{
if (g_map_count <= 0) {
g_map_cx = 0.5;
g_map_cy = 0.5;
g_map_scale = MAP_SCALE_MIN;
map_clamp_view();
return;
}
double minx = 1e9, maxx = -1e9, miny = 1e9, maxy = -1e9;
for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
if (mx < minx) minx = mx;
if (mx > maxx) maxx = mx;
if (my < miny) miny = my;
if (my > maxy) maxy = my;
}
g_map_cx = (minx + maxx) / 2.0;
g_map_cy = (miny + maxy) / 2.0;
double w = maxx - minx, h = maxy - miny;
if (w < 1e-9) w = 1e-9;
if (h < 1e-9) h = 1e-9;
double sx = SCREEN_W * 0.82 / w, sy = SCREEN_H * 0.82 / h;
g_map_scale = sx < sy ? sx : sy;
/* a single point fits at "infinite" zoom — pull back to the max zoom */
if (g_map_scale > MAP_SCALE_MAX)
g_map_scale = MAP_SCALE_MAX;
map_clamp_view();
}
/* clusters, rebuilt every frame from the on-screen markers. markers are
* bucketed into a fixed screen grid; each non-empty cell becomes one bubble
* at its members' centroid. g_cl_rep is a member's marker index, used to open
* the photo when a bubble holds just one. */
#define MAP_CELL 58.0f
#define MAP_MAXCLUST 600
static float g_cl_x[MAP_MAXCLUST], g_cl_y[MAP_MAXCLUST];
static int g_cl_gx[MAP_MAXCLUST], g_cl_gy[MAP_MAXCLUST]; /* merc-grid cell */
static int g_cl_n[MAP_MAXCLUST];
static int g_cl_rep[MAP_MAXCLUST];
static double g_cl_sx[MAP_MAXCLUST], g_cl_sy[MAP_MAXCLUST]; /* screen sums */
static float g_cl_x0[MAP_MAXCLUST], g_cl_y0[MAP_MAXCLUST]; /* member screen bbox */
static float g_cl_x1[MAP_MAXCLUST], g_cl_y1[MAP_MAXCLUST];
static int g_cl_count;
/* Group markers into bubbles. The grid cells are anchored in MAP (Web-Mercator)
* space, not screen space, so panning leaves each marker in the same cell and
* the bubbles simply translate with the map instead of reshuffling/jumping.
*
* The cell size is keyed off a scale SNAPPED to the nearest power-of-two octave,
* not the live scale: the live scale changes every frame while zooming, which
* drifts the cell boundaries so markers hop cells and the bubbles jitter. With
* the snap, the grid is fixed across a ~1.4x zoom band and only re-forms once
* per octave, so during a zoom the bubbles just glide/scale with the map. */
static void map_cluster(void)
{
g_cl_count = 0;
if (g_map_scale <= 0.0)
return;
double qscale = pow(2.0, round(log2(g_map_scale))); /* octave-snapped */
double cellsz = (double)MAP_CELL / qscale; /* merc units per cell */
for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
float sx, sy;
map_project(mx, my, &sx, &sy);
/* only consider markers near the viewport (generous margin so a
* bubble straddling the edge keeps a steady count while panning) */
if (sx < -90 || sx > SCREEN_W + 90 ||
sy < -90 || sy > SCREEN_H + 90)
continue;
int gx = (int)(mx / cellsz), gy = (int)(my / cellsz);
int c = -1;
for (int j = 0; j < g_cl_count; j++)
if (g_cl_gx[j] == gx && g_cl_gy[j] == gy) {
c = j;
break;
}
if (c < 0) {
if (g_cl_count >= MAP_MAXCLUST)
continue; /* grid saturated: drop the overflow */
c = g_cl_count++;
g_cl_gx[c] = gx;
g_cl_gy[c] = gy;
g_cl_n[c] = 0;
g_cl_sx[c] = g_cl_sy[c] = 0.0;
g_cl_x0[c] = g_cl_x1[c] = sx;
g_cl_y0[c] = g_cl_y1[c] = sy;
g_cl_rep[c] = i;
}
g_cl_n[c]++;
g_cl_sx[c] += sx;
g_cl_sy[c] += sy;
if (sx < g_cl_x0[c]) g_cl_x0[c] = sx;
if (sx > g_cl_x1[c]) g_cl_x1[c] = sx;
if (sy < g_cl_y0[c]) g_cl_y0[c] = sy;
if (sy > g_cl_y1[c]) g_cl_y1[c] = sy;
}
for (int j = 0; j < g_cl_count; j++) {
g_cl_x[j] = (float)(g_cl_sx[j] / g_cl_n[j]);
g_cl_y[j] = (float)(g_cl_sy[j] / g_cl_n[j]);
}
}
/* bubble radius (px) for a member count: lone markers are small dots, the
* rest grow with log(count) so a 200-photo cluster isn't 200x a 2-photo one */
static float map_bubble_r(int count)
{
if (count <= 1)
return 7.0f;
float r = 14.0f + 9.0f * log10f((float)count);
return r > 40.0f ? 40.0f : r;
}
/* cluster nearest the screen centre (what X acts on), or -1 if none */
static int map_nearest_cluster(void)
{
int best = -1;
float bestd = 1e18f;
for (int j = 0; j < g_cl_count; j++) {
float dx = g_cl_x[j] - SCREEN_W / 2.0f;
float dy = g_cl_y[j] - SCREEN_H / 2.0f;
float d = dx * dx + dy * dy;
if (d < bestd) {
bestd = d;
best = j;
}
}
return best;
}
/* cluster whose bubble contains screen point (x,y), or -1: touch hit-test */
static int map_cluster_at(float x, float y)
{
for (int j = 0; j < g_cl_count; j++) {
float r = map_bubble_r(g_cl_n[j]) + 6.0f;
float dx = g_cl_x[j] - x, dy = g_cl_y[j] - y;
if (dx * dx + dy * dy <= r * r)
return j;
}
return -1;
}
/* resolve a marker's asset to a display slot, opening it in the detail view.
* the map lists the whole server, so the asset is often not among the pages
* fetched for the grid — in that case fetch its metadata, append it to the
* library and rebuild the timeline so it has a slot. main-thread only (same
* pattern as fetch_page). returns 1 and fills *out_sel on success. */
static int map_open_marker(int marker_idx, int *out_sel)
{
if (marker_idx < 0 || marker_idx >= g_map_count)
return 0;
const char *id = g_map_id[marker_idx];
int idx = -1;
for (int i = 0; i < g_asset_count; i++)
if (!strcmp(g_asset_ids[i], id)) {
idx = i;
break;
}
if (idx < 0) {
char url[600];
snprintf(url, sizeof(url), "%s/api/assets/%s", g_server, id);
membuf buf;
long code;
CURLcode r = http_request(url, NULL, &buf, &code, NULL, 0);
if (r != CURLE_OK || code < 200 || code >= 300) {
log_line("map open %s: curl %d http %ld", id, r, code);
free(buf.data);
return 0;
}
char dval[DATELEN] = "", tval[12] = "";
json_get(buf.data, buf.size, "fileCreatedAt", dval, sizeof(dval));
json_get(buf.data, buf.size, "type", tval, sizeof(tval));
free(buf.data);
if (!grow_assets(g_asset_count + 1))
return 0;
idx = g_asset_count++;
snprintf(g_asset_ids[idx], sizeof(g_asset_ids[0]), "%s", id);
snprintf(g_asset_dates[idx], sizeof(g_asset_dates[0]), "%s", dval);
g_asset_ratio[idx] = 0.0f; /* unknown: detail view fits anyway */
g_asset_rot[idx] = 0;
g_asset_is_video[idx] = !strncmp(tval, "VIDEO", 5);
g_asset_local_backed[idx] = 0;
g_asset_hidden[idx] = 0;
g_thumb[idx] = NULL;
g_thumb_failed[idx] = 0;
g_tex_err[idx][0] = '\0';
/* opening from the map shouldn't be hijacked by an active search */
g_search_active = 0;
rebuild_display();
log_line("map open: fetched asset %s (%s)", id,
g_asset_is_video[idx] ? "video" : "photo");
}
int s = find_disp(SRC_SERVER, idx);
if (s >= 0) {
*out_sel = s;
return 1;
}
return 0;
}
/* latitude band of the source image, in normalised Web-Mercator. NOT simply
* image-height/width: the source's vertical scale differs ~0.4% from its
* horizontal, so these were fit against the image's baked-in graticule (the
* equator sits at y=3213 of 6352, ~37px below image centre). ~83.7°N .. -83.3°S. */
#define MAP_IMG_MY0 0.03814 /* top edge (~83.7°N) */
#define MAP_IMG_MY1 0.95125 /* bottom edge (~-83.3°S) */
/* ------------------------------------------------------------------ */
/* raster tile pyramid (app0:maptiles.pak) — the LOD base map. */
/* Generated from the MapChart PNG by tools/png_to_tiles.py: several */
/* zoom levels, each cut into 256px JPEG tiles. We pick the level whose */
/* native width matches the on-screen map width (crisp), and draw only */
/* the visible tiles, decoding them into pooled textures (the safe path */
/* — freeing textures mid-run crashes Vita3K's texture cache). Tiles */
/* span mx[0,1] x my[MAP_IMG_MY0,MAP_IMG_MY1], same georeferencing as */
/* the markers, so everything lines up. */
/* ------------------------------------------------------------------ */
#define TILE_PX 256
#define MAP_MAXLEV 8
static void map_draw_grid(void); /* fallback, defined below */
static FILE *g_pak;
static long g_pak_blob; /* file offset where JPEG data begins */
static int g_pak_nlev;
static int g_lev_w[MAP_MAXLEV], g_lev_h[MAP_MAXLEV]; /* content px */
static int g_lev_cols[MAP_MAXLEV], g_lev_rows[MAP_MAXLEV];
static int g_lev_base[MAP_MAXLEV]; /* index of this level's first tile */
static unsigned int *g_ti_off, *g_ti_size;
static int g_ti_n;
#define TCACHE 80
static struct tile_slot {
int lev, col, row;
vita2d_texture *tex;
unsigned use;
} g_tc[TCACHE];
static unsigned g_tc_clock;
static int g_tile_budget; /* new tile decodes allowed this frame */
static void load_maptiles(void)
{
FILE *f = fopen("app0:maptiles.pak", "rb");
if (!f)
return;
char magic[4];
unsigned int ver, tile, nlev;
if (fread(magic, 1, 4, f) != 4 || memcmp(magic, "MTIL", 4) ||
fread(&ver, 4, 1, f) != 1 || fread(&tile, 4, 1, f) != 1 ||
fread(&nlev, 4, 1, f) != 1 ||
tile != TILE_PX || nlev == 0 || nlev > MAP_MAXLEV) {
fclose(f);
return;
}
g_pak_nlev = (int)nlev;
for (int L = 0; L < g_pak_nlev; L++) {
unsigned int w, h, c, r;
if (fread(&w, 4, 1, f) != 1 || fread(&h, 4, 1, f) != 1 ||
fread(&c, 4, 1, f) != 1 || fread(&r, 4, 1, f) != 1) {
fclose(f);
return;
}
g_lev_w[L] = w; g_lev_h[L] = h;
g_lev_cols[L] = c; g_lev_rows[L] = r;
}
unsigned int nt;
if (fread(&nt, 4, 1, f) != 1 || nt == 0 || nt > 200000) {
fclose(f);
return;
}
g_ti_off = malloc(nt * sizeof(*g_ti_off));
g_ti_size = malloc(nt * sizeof(*g_ti_size));
if (!g_ti_off || !g_ti_size) {
free(g_ti_off); free(g_ti_size);
g_ti_off = g_ti_size = NULL;
fclose(f);
return;
}
int curlev = -1;
unsigned int i;
for (i = 0; i < nt; i++) {
unsigned short lev, col, row;
unsigned int off, sz;
if (fread(&lev, 2, 1, f) != 1 || fread(&col, 2, 1, f) != 1 ||
fread(&row, 2, 1, f) != 1 || fread(&off, 4, 1, f) != 1 ||
fread(&sz, 4, 1, f) != 1)
break;
g_ti_off[i] = off;
g_ti_size[i] = sz;
if ((int)lev != curlev && lev < g_pak_nlev) {
curlev = lev;
g_lev_base[curlev] = (int)i;
}
}
g_pak_blob = ftell(f); /* index fully read: blob starts here */
g_ti_n = (int)i;
g_pak = f; /* kept open for on-demand tile reads */
log_line("maptiles: %d levels, %d tiles", g_pak_nlev, g_ti_n);
}
/* read + decode one tile JPEG into a pooled texture (NULL on any failure) */
static vita2d_texture *tile_decode(int L, int col, int row)
{
if (!g_pak || L < 0 || L >= g_pak_nlev ||
col < 0 || col >= g_lev_cols[L] || row < 0 || row >= g_lev_rows[L])
return NULL;
int ti = g_lev_base[L] + row * g_lev_cols[L] + col;
if (ti < 0 || ti >= g_ti_n)
return NULL;
unsigned int off = g_ti_off[ti], sz = g_ti_size[ti];
if (sz == 0 || sz > 4u * 1024 * 1024)
return NULL;
char *buf = malloc(sz);
if (!buf)
return NULL;
if (fseek(g_pak, g_pak_blob + off, SEEK_SET) != 0 ||
fread(buf, 1, sz, g_pak) != sz) {
free(buf);
return NULL;
}
int w, h, comps;
char err[64];
unsigned char *pix = decode_jpeg_buf(buf, sz, TILE_PX, &w, &h, &comps,
err, sizeof(err));
free(buf);
if (!pix) {
log_line("tile %d/%d/%d: %s", L, col, row, err);
return NULL;
}
SceGxmTextureFormat fmt = (comps == 1) ?
SCE_GXM_TEXTURE_FORMAT_U8_R111 :
SCE_GXM_TEXTURE_FORMAT_U8U8U8_BGR;
vita2d_texture *t = tex_acquire(w, h, fmt);
if (t) {
unsigned char *dst = vita2d_texture_get_datap(t);
unsigned int stride = vita2d_texture_get_stride(t);
unsigned int rb = w * comps;
for (int y = 0; y < h; y++)
memcpy(dst + y * stride, pix + y * rb, rb);
vita2d_texture_set_filters(t, SCE_GXM_TEXTURE_FILTER_LINEAR,
SCE_GXM_TEXTURE_FILTER_LINEAR);
}
free(pix);
return t;
}
/* fetch a tile from the cache, optionally loading (budget-limited) on a miss */
static vita2d_texture *tile_get(int L, int col, int row, int may_load)
{
for (int i = 0; i < TCACHE; i++)
if (g_tc[i].tex && g_tc[i].lev == L &&
g_tc[i].col == col && g_tc[i].row == row) {
g_tc[i].use = ++g_tc_clock;
return g_tc[i].tex;
}
if (!may_load || g_tile_budget <= 0)
return NULL;
vita2d_texture *t = tile_decode(L, col, row);
if (!t)
return NULL;
g_tile_budget--;
int slot = -1;
unsigned oldest = 0xffffffffu;
for (int i = 0; i < TCACHE; i++) {
if (!g_tc[i].tex) { slot = i; break; }
if (g_tc[i].use < oldest) { oldest = g_tc[i].use; slot = i; }
}
if (g_tc[slot].tex)
tex_release(g_tc[slot].tex); /* recycle, never free */
g_tc[slot].tex = t;
g_tc[slot].lev = L;
g_tc[slot].col = col;
g_tc[slot].row = row;
g_tc[slot].use = ++g_tc_clock;
return t;
}
/* the LOD whose native width best matches the on-screen map width */
static int tile_pick_level(void)
{
int L = 0;
while (L < g_pak_nlev - 1 && g_lev_w[L] < (int)g_map_scale)
L++;
return L;
}
/* draw every visible tile of level L (missing ones leave the coarser level
* below showing through, so there are no blank gaps while tiles stream in) */
static void map_draw_level(int L)
{
if (L < 0 || L >= g_pak_nlev)
return;
double span = MAP_IMG_MY1 - MAP_IMG_MY0;
double lw = g_lev_w[L], lh = g_lev_h[L];
for (int r = 0; r < g_lev_rows[L]; r++) {
for (int c = 0; c < g_lev_cols[L]; c++) {
double mx0 = (c * TILE_PX) / lw;
double mx1 = ((c + 1) * TILE_PX) / lw;
double my0 = MAP_IMG_MY0 + ((r * TILE_PX) / lh) * span;
double my1 = MAP_IMG_MY0 + (((r + 1) * TILE_PX) / lh) * span;
float sx0, sy0, sx1, sy1;
map_project(mx0, my0, &sx0, &sy0);
map_project(mx1, my1, &sx1, &sy1);
if (sx1 < -1 || sx0 > SCREEN_W + 1 ||
sy1 < -1 || sy0 > SCREEN_H + 1)
continue;
vita2d_texture *t = tile_get(L, c, r, 1);
if (!t)
continue;
vita2d_draw_texture_scale(t, sx0, sy0,
(sx1 - sx0) / TILE_PX, (sy1 - sy0) / TILE_PX);
}
}
}
/* the tiled base map: a coarse overview first (no gaps), then the chosen LOD */
static void map_draw_tiles(void)
{
g_tile_budget = 6; /* cap new decodes per frame (avoids hitching) */
if (g_pak_nlev <= 0) {
map_draw_grid(); /* fallback if the pak is missing */
return;
}
map_draw_level(0);
int L = tile_pick_level();
if (L > 0)
map_draw_level(L);
}
/* faint lat/lon graticule for orientation (the only "base map" there is) */
static void map_draw_grid(void)
{
uint32_t c = RGBA8(40, 44, 54, 255);
for (int lon = -180; lon <= 180; lon += 30) {
float sx, sy;
map_project(merc_x(lon), 0.0, &sx, &sy);
if (sx >= 0 && sx <= SCREEN_W)
vita2d_draw_rectangle(sx, 0, 1, SCREEN_H, c);
}
for (int lat = -75; lat <= 75; lat += 15) {
float sx, sy;
map_project(0.0, merc_y(lat), &sx, &sy);
if (sy >= 0 && sy <= SCREEN_H)
vita2d_draw_rectangle(0, sy, SCREEN_W, 1, c);
}
}
/* Lat/lon graticule, projected with merc_x/merc_y + MAP_LON0/1 + MAP_IMG_MY0/1
* (the same maths the markers use), drawn bright over the tiled base map at 10°
* spacing. The current city-label base map has no printed grid, so this is the
* only graticule; against the graticule export it coincided to ~1px, which is
* how the georeferencing was validated. Prime meridian and equator are brighter
* to anchor orientation. Toggle with TRIANGLE. */
static void map_draw_graticule(void)
{
uint32_t reg = RGBA8(225, 45, 45, 140); /* translucent red */
uint32_t axis = RGBA8(255, 90, 90, 230); /* lon 0 / equator */
/* clip the overlay to the base-map rectangle (clamped to the screen) so
* each line spans only where the map is, not the black margins around it */
float lx, ty, rx, by;
map_project(0.0, MAP_IMG_MY0, &lx, &ty); /* image top-left */
map_project(1.0, MAP_IMG_MY1, &rx, &by); /* image bot-right */
if (lx < 0) lx = 0;
if (ty < 0) ty = 0;
if (rx > SCREEN_W) rx = SCREEN_W;
if (by > SCREEN_H) by = SCREEN_H;
for (int lon = -180; lon <= 180 && by > ty; lon += 10) {
float sx, sy;
map_project(merc_x(lon), 0.0, &sx, &sy);
if (sx >= 0 && sx <= SCREEN_W)
vita2d_draw_rectangle(sx, ty, 1, by - ty,
lon == 0 ? axis : reg);
}
for (int lat = -80; lat <= 80 && rx > lx; lat += 10) {
float sx, sy;
map_project(0.0, merc_y(lat), &sx, &sy);
if (sy >= 0 && sy <= SCREEN_H)
vita2d_draw_rectangle(lx, sy, rx - lx, 1,
lat == 0 ? axis : reg);
}
}
/* draw every cluster bubble; `sel` gets a white selection ring */
static void map_draw_clusters(int sel)
{
uint32_t fill = RGBA8(94, 110, 215, 235);
uint32_t dot = RGBA8(120, 140, 235, 235);
uint32_t white = RGBA8(245, 245, 250, 255);
for (int j = 0; j < g_cl_count; j++) {
float x = g_cl_x[j], y = g_cl_y[j];
int cnt = g_cl_n[j];
float r = map_bubble_r(cnt);
if (j == sel)
draw_disc(x, y, (r + 3.0f) * 2.0f, white);
if (cnt <= 1) {
draw_disc(x, y, r * 2.0f, dot);
continue;
}
draw_disc(x, y, r * 2.0f, fill);
char b[12];
snprintf(b, sizeof(b), "%d", cnt);
int w = text_width(0.8f, b);
draw_text(x - w / 2.0f, y + 6.0f, white, 0.8f, b);
}
}
/* ------------------------------------------------------------------ */
/* cluster gallery */
/* */
/* Selecting a bubble shows every photo it groups on the SAME grid as */
/* the timeline/search — a filtered view (g_gallery_*). Members already */
/* paged into the library are reused; the rest are appended as hidden */
/* g_asset entries (id + thumb only) whose date/aspect the metadata */
/* worker fills in lazily, so the justified grid sorts into month */
/* sections and true aspect ratios as the data arrives. */
/* ------------------------------------------------------------------ */
/* g_asset index for an id: an existing entry, or a freshly appended hidden one
* (lets the gallery show a marker not yet paged into the library). -1 on OOM. */
static int asset_for_id(const char *id)
{
for (int i = 0; i < g_asset_count; i++)
if (!strcmp(g_asset_ids[i], id))
return i;
if (!grow_assets(g_asset_count + 1))
return -1;
int idx = g_asset_count++;
snprintf(g_asset_ids[idx], sizeof(g_asset_ids[0]), "%s", id);
g_asset_dates[idx][0] = '\0'; /* "" = unresolved; meta_worker fills it */
g_asset_ratio[idx] = 0.0f;
g_asset_rot[idx] = 0;
g_asset_is_video[idx] = 0;
g_asset_local_backed[idx] = 0;
g_asset_hidden[idx] = 1; /* gallery-only: kept out of the timeline */
g_thumb[idx] = NULL;
g_thumb_failed[idx] = 0;
g_tex_err[idx][0] = '\0';
return idx;
}
/* open the gallery for cluster c: collect its members (same merc cell as the
* bubble) into g_gallery_idx, then rebuild the grid to show them */
static void gallery_open(int c)
{
g_gallery_count = 0;
if (c < 0 || c >= g_cl_count || g_map_scale <= 0.0)
return;
double qscale = pow(2.0, round(log2(g_map_scale)));
double cellsz = (double)MAP_CELL / qscale;
int tgx = g_cl_gx[c], tgy = g_cl_gy[c];
for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
float sx, sy;
map_project(mx, my, &sx, &sy);
if (sx < -90 || sx > SCREEN_W + 90 ||
sy < -90 || sy > SCREEN_H + 90)
continue;
if ((int)(mx / cellsz) != tgx || (int)(my / cellsz) != tgy)
continue;
int idx = asset_for_id(g_map_id[i]);
if (idx < 0 || !grow_gallery(g_gallery_count + 1))
break;
g_gallery_idx[g_gallery_count++] = idx;
}
g_gallery_active = 1;
rebuild_display();
}
static void gallery_close(void)
{
g_gallery_active = 0;
rebuild_display();
}
/* lazy fill: apply a finished metadata result (re-laying out the grid so the
* item drops into its month/aspect), then request the next missing one —
* visible slots first. g_meta_target is a g_asset index, so it stays valid
* across the rebuilds that reorder g_disp. */
static void gallery_meta_pump(int first_vis, int last_vis,
int *sel, float *scroll, float *target)
{
if (g_meta_state == META_DONE) {
int t = g_meta_target;
if (t >= 0 && t < g_asset_count && g_meta_date[0]) {
snprintf(g_asset_dates[t], sizeof(g_asset_dates[0]), "%s",
g_meta_date);
g_asset_ratio[t] = g_meta_ratio;
g_asset_rot[t] = g_meta_rot;
g_asset_is_video[t] = (unsigned char)g_meta_is_video;
} else if (t >= 0 && t < g_asset_count) {
/* fetch failed: stamp a placeholder date so we don't
* spin on it forever (it just won't group by month) */
snprintf(g_asset_dates[t], sizeof(g_asset_dates[0]),
"0000-00-00");
}
g_meta_state = META_IDLE;
rebuild_keep_view(sel, scroll, target);
}
if (g_meta_state != META_IDLE)
return;
int pick = -1;
for (int d = first_vis; pick < 0 && d >= 0 && d <= last_vis &&
d < g_disp_count; d++)
if (g_disp[d].src == SRC_SERVER &&
g_asset_dates[g_disp[d].idx][0] == '\0')
pick = g_disp[d].idx;
for (int k = 0; pick < 0 && k < g_gallery_count; k++)
if (g_asset_dates[g_gallery_idx[k]][0] == '\0')
pick = g_gallery_idx[k];
if (pick < 0)
return;
g_meta_target = pick;
snprintf(g_meta_id, sizeof(g_meta_id), "%s", g_asset_ids[pick]);
__sync_synchronize();
g_meta_state = META_PENDING;
}
+350
View File
@@ -0,0 +1,350 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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(&param);
}
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;
}
/* write the right auth header: a login token (Bearer) wins over an API key.
* used by the worker/sync curl handles that build their own header lists. */
static void auth_header(char *buf, size_t n)
{
if (g_token[0])
snprintf(buf, n, "Authorization: Bearer %s", g_token);
else
snprintf(buf, n, "x-api-key: %s", g_apikey);
}
/* 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;
/* auth: a login token (Bearer) wins over an API key; during the login
* request itself both are empty, so no auth header is sent */
char authhdr[320];
struct curl_slist *hdrs = NULL;
if (g_token[0]) {
snprintf(authhdr, sizeof(authhdr), "Authorization: Bearer %s",
g_token);
hdrs = curl_slist_append(hdrs, authhdr);
} else if (g_apikey[0]) {
snprintf(authhdr, sizeof(authhdr), "x-api-key: %s", g_apikey);
hdrs = curl_slist_append(hdrs, authhdr);
}
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;
}
/* copy the value of a top-level JSON key into `out` (raw token text, works
* for both string and number values). returns 1 if found. small flat
* responses only (server storage / version). */
static int json_get(const char *js, size_t len, const char *key,
char *out, size_t outlen)
{
jsmn_parser p;
jsmn_init(&p);
int n = jsmn_parse(&p, js, len, NULL, 0);
if (n <= 0)
return 0;
jsmntok_t *t = malloc(sizeof(*t) * n);
if (!t)
return 0;
jsmn_init(&p);
n = jsmn_parse(&p, js, len, t, n);
int kl = (int)strlen(key), got = 0;
for (int i = 1; i + 1 < n; i++) {
if (t[i].type == JSMN_STRING && t[i].parent == 0 &&
t[i].end - t[i].start == kl &&
!strncmp(js + t[i].start, key, kl)) {
int vl = t[i + 1].end - t[i + 1].start;
if (vl >= (int)outlen)
vl = (int)outlen - 1;
memcpy(out, js + t[i + 1].start, vl);
out[vl] = '\0';
got = 1;
break;
}
}
free(t);
return got;
}
/* log in with email/password and stash the access token in g_token (sent as
* Authorization: Bearer afterwards). returns 0 on success. */
static int do_login(char *err, size_t errlen)
{
/* JSON-escape email + password into the request body */
char esc[400];
int e = 0;
const char *fields[2] = { g_email, g_password };
const char *names[2] = { "email", "password" };
e += snprintf(esc + e, sizeof(esc) - e, "{");
for (int f = 0; f < 2; f++) {
e += snprintf(esc + e, sizeof(esc) - e, "%s\"%s\":\"",
f ? "," : "", names[f]);
for (const char *p = fields[f]; *p && e < (int)sizeof(esc) - 8; p++) {
unsigned char ch = (unsigned char)*p;
if (ch == '"' || ch == '\\') {
esc[e++] = '\\';
esc[e++] = (char)ch;
} else if (ch >= 0x20) {
esc[e++] = (char)ch;
}
}
e += snprintf(esc + e, sizeof(esc) - e, "\"");
}
e += snprintf(esc + e, sizeof(esc) - e, "}");
char url[600];
snprintf(url, sizeof(url), "%s/api/auth/login", g_server);
membuf buf;
long code;
CURLcode r = http_request(url, esc, &buf, &code, NULL, 0);
if (r != CURLE_OK) {
snprintf(err, errlen, "%s", curl_easy_strerror(r));
free(buf.data);
return -1;
}
if (code < 200 || code >= 300) {
snprintf(err, errlen, "HTTP %ld: %.80s", code,
buf.data ? buf.data : "");
free(buf.data);
return -1;
}
if (!buf.data || !json_get(buf.data, buf.size, "accessToken",
g_token, sizeof(g_token))) {
snprintf(err, errlen, "no accessToken in response");
free(buf.data);
return -1;
}
free(buf.data);
log_line("login ok: token %.8s... for %s", g_token, g_email);
return 0;
}
/* GET a JSON endpoint, trying the modern path then a legacy fallback. fills
* `out`/`code`; caller frees out->data. returns CURLcode of the call used. */
static CURLcode server_get(const char *path, const char *legacy,
membuf *out, long *code)
{
char url[600];
snprintf(url, sizeof(url), "%s%s", g_server, path);
CURLcode r = http_request(url, NULL, out, code, NULL, 0);
if (r == CURLE_OK && (*code < 200 || *code >= 300) && legacy) {
free(out->data);
snprintf(url, sizeof(url), "%s%s", g_server, legacy);
r = http_request(url, NULL, out, code, NULL, 0);
}
return r;
}
/* fetch disk usage + server version into the g_srv_* fields (main thread) */
static void fetch_server_info(void)
{
membuf buf;
long code;
CURLcode r = server_get("/api/server/storage",
"/api/server-info/storage", &buf, &code);
if (r == CURLE_OK && code >= 200 && code < 300 && buf.data) {
char pct[24] = "";
if (!json_get(buf.data, buf.size, "diskUse",
g_srv_use, sizeof(g_srv_use)))
g_srv_use[0] = '\0';
if (!json_get(buf.data, buf.size, "diskSize",
g_srv_total, sizeof(g_srv_total)))
g_srv_total[0] = '\0';
if (json_get(buf.data, buf.size, "diskUsagePercentage",
pct, sizeof(pct)))
g_srv_pct = (int)(atof(pct) + 0.5);
g_srv_state = 1;
} else {
log_line("server storage: curl %d http %ld", r, code);
g_srv_state = -1;
}
free(buf.data);
r = server_get("/api/server/version", "/api/server-info/version",
&buf, &code);
if (r == CURLE_OK && code >= 200 && code < 300 && buf.data) {
char mj[12] = "", mn[12] = "", pt[12] = "";
json_get(buf.data, buf.size, "major", mj, sizeof(mj));
json_get(buf.data, buf.size, "minor", mn, sizeof(mn));
json_get(buf.data, buf.size, "patch", pt, sizeof(pt));
snprintf(g_srv_version, sizeof(g_srv_version), "v%s.%s.%s",
mj[0] ? mj : "?", mn[0] ? mn : "?", pt[0] ? pt : "?");
}
free(buf.data);
}
+333
View File
@@ -0,0 +1,333 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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 and
* width/height of the cell (justified rows: width follows the photo's
* aspect, the row is stretched to span the full screen width), plus the
* month/year section headers above each month's run */
static float *g_item_x;
static float *g_item_y;
static float *g_item_w;
static float *g_item_h;
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 */
/* map cluster gallery: the photos of one map bubble, shown on the SAME grid as
* the timeline/search — a filtered view like g_search_*. g_gallery_idx holds
* indices into the g_asset_* arrays for the bubble's members; members not
* already paged into the library are appended as g_asset entries flagged
* g_asset_hidden (so they appear only here, never in the main timeline). Their
* date/aspect are resolved lazily by the metadata worker below, so the grid
* reflows into real month sections + true aspect ratios as they arrive. */
static int g_gallery_active;
static int *g_gallery_idx;
static int g_gallery_count;
static int g_gallery_cap;
/* background metadata resolver: a one-slot handoff (mirroring the thumb worker)
* that fetches one asset's date/aspect so the justified grid can place it. */
enum { META_IDLE, META_PENDING, META_DONE };
static volatile int g_meta_state = META_IDLE;
static char g_meta_id[40]; /* asset id to resolve (main -> worker) */
static int g_meta_target; /* g_asset index to fill (main-thread owned) */
static char g_meta_date[DATELEN]; /* results (worker -> main) */
static float g_meta_ratio;
static unsigned char g_meta_rot;
static int g_meta_is_video;
/* free-text smart (CLIP) search. when active the grid shows only the matching
* server assets — still date-grouped under month headers like the timeline —
* instead of the full merged library; TRIANGLE opens the keyboard, CIRCLE (or
* the bar's clear chip) returns to the timeline. g_search_idx holds indices
* into the g_asset_* arrays (those never reorder, only grow), in the order the
* server returned them. */
static int g_search_active;
static char g_search_query[128];
static int *g_search_idx;
static int g_search_count;
static int g_search_cap;
/* smart search runs on a worker thread so the grid can animate a throbber
* while the request is in flight; g_search_pending is the query it runs,
* g_search_done/g_search_result report completion + its return value */
static char g_search_pending[128];
static volatile int g_search_done;
static volatile int g_search_result;
/* server info shown on the cloud/backup page; fetched lazily the first time
* the page is opened. g_srv_state: 0 not fetched, 1 ok, -1 failed.
* g_srv_fetching: a worker thread is fetching it now (so the page animates a
* throbber instead of blocking the main thread on the request). */
static int g_srv_state;
static volatile int g_srv_fetching;
static char g_srv_use[40]; /* disk used, human ("1.4 TiB") */
static char g_srv_total[40]; /* disk size, human ("1.8 TiB") */
static int g_srv_pct; /* disk usage percentage */
static char g_srv_version[40]; /* "v1.119.0" */
/* account switch (logout -> login): pause the sync thread while the main
* thread drops the old library and reloads, so it can't read/realloc the
* arrays mid-reset */
static volatile int g_pause_bg; /* request the sync thread to idle */
static volatile int g_sync_idle; /* sync thread acknowledges it idled */
/* ---- albums (read-only browsing) ------------------------------------ */
/* The album LIST lives in the g_album_* arrays, filled by GET /api/albums on
* a worker thread (the main thread only draws a throbber meanwhile). Each
* entry keeps the server's own ordering.
*
* Opening an album switches the grid to that album's assets, exactly like the
* smart-search result set: g_album_active marks the filter and g_album_idx
* holds indices into the g_asset_* arrays (which only ever grow). The members
* are fetched page by page with /api/search/metadata (withExif, so they carry
* date + aspect and drop straight into month sections).
*
* g_album_state: 0 not fetched yet, 1 ok, -1 failed. g_album_fetching: a
* worker thread is fetching the list right now. */
static char (*g_album_id)[40]; /* album uuid */
static char (*g_album_name)[64]; /* display name */
static char (*g_album_thumb)[40]; /* cover asset id, "" when none */
static int *g_album_nassets; /* assetCount reported by the server */
static unsigned char *g_album_shared; /* shared with other users */
static unsigned char *g_album_has_link; /* has a public share link */
static int g_album_len; /* number of albums fetched */
static int g_album_cap;
static int g_album_state;
static volatile int g_album_fetching;
static int g_album_active; /* grid is showing one album's assets */
static int g_album_open = -1; /* index into g_album_* being shown */
static int *g_album_idx; /* g_asset indices of its members */
static int g_album_idx_count;
static int g_album_idx_cap;
/* open-album fetch (worker thread): done flag, result, members so far */
static volatile int g_album_assets_done;
static volatile int g_album_assets_result;
static volatile int g_album_assets_loaded;
/* each album's cover as a g_asset index (asset_for_id), so the shared
* thumbnail worker loads it into g_thumb[] like any grid thumbnail.
* -1 = no cover (empty album) or not resolved yet. */
static int *g_album_cover;
/* Texture recycling pool. Freeing a texture unmaps its memblock, and the
* GPU side (notably Vita3K's texture cache, which re-reads guest memory of
* cached textures at its own pace) may still touch it afterwards — freeing
* 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_ratio, c, need) ||
!GROW(g_asset_rot, c, need) ||
!GROW(g_asset_is_video, c, need) ||
!GROW(g_asset_local_backed, c, need) ||
!GROW(g_asset_hidden, 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) ||
!GROW(g_item_w, c, need) ||
!GROW(g_item_h, 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 int grow_gallery(int need)
{
if (need <= g_gallery_cap)
return 1;
int cap = GROW(g_gallery_idx, g_gallery_cap, need);
if (!cap)
return 0;
g_gallery_cap = cap;
return 1;
}
/* ensure the album arrays hold at least `need` entries */
static int grow_albums(int need)
{
int c = g_album_cap;
if (need <= c)
return 1;
if (!GROW(g_album_id, c, need) ||
!GROW(g_album_name, c, need) ||
!GROW(g_album_thumb, c, need) ||
!GROW(g_album_nassets, c, need) ||
!GROW(g_album_shared, c, need) ||
!GROW(g_album_has_link, c, need) ||
!(g_album_cap = GROW(g_album_cover, c, need)))
return 0;
return 1;
}
/* ensure an open album's member index array holds at least `need` entries */
static int grow_album_idx(int need)
{
if (need <= g_album_idx_cap)
return 1;
int cap = GROW(g_album_idx, g_album_idx_cap, need);
if (!cap)
return 0;
g_album_idx_cap = cap;
return 1;
}
static void log_line(const char *fmt, ...)
{
FILE *f = fopen(LOG_PATH, "a");
if (!f)
return;
va_list ap;
va_start(ap, fmt);
vfprintf(f, fmt, ap);
va_end(ap);
fputc('\n', f);
fclose(f);
}
+636
View File
@@ -0,0 +1,636 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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;
}
/* set while re-scanning after a resume from sleep: scan_dir then only appends
* genuinely-new files into the headroom (no realloc; the worker/sync threads
* are reading the arrays) */
static int g_rescan;
/* 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;
}
/* rescan (resume from sleep): skip files we already know, and
* never realloc while the sync/worker threads are reading the
* arrays — only fill into the headroom reserved at startup */
if (g_rescan) {
int known = 0;
for (int k = 0; k < g_local_count && !known; k++)
known = !strcmp(g_local_path[k], full);
if (known)
continue;
if (g_local_count >= g_local_cap) {
log_line("rescan: no headroom at %d files",
g_local_count);
break;
}
} else 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; /* fill, then publish (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;
__sync_synchronize();
g_local_count = j + 1;
}
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);
}
/* append-only re-scan of the camera folders (resume from sleep). same dirs as
* scan_local_media, but g_rescan keeps it from touching known entries or
* reallocating. returns how many new files were appended. */
static int rescan_local_new(void)
{
int before = g_local_count;
g_rescan = 1;
if (g_syncdir_count > 0) {
for (int i = 0; i < g_syncdir_count; i++)
scan_dir(g_syncdirs[i], 3);
} else {
scan_dir(g_photo0_mounted ? "photo0:" : "ux0:picture", 3);
scan_dir("ux0:video/CAMERA", 3);
}
g_rescan = 0;
int added = g_local_count - before;
if (added > 0)
log_line("rescan: %d new local file(s) after resume", added);
return added;
}
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];
auth_header(keyhdr, sizeof(keyhdr));
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];
auth_header(keyhdr, sizeof(keyhdr));
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];
auth_header(keyhdr, sizeof(keyhdr));
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 (;;) {
/* idle while the main thread resets for an account switch */
if (g_pause_bg) {
g_sync_idle = 1;
sceKernelDelayThread(8 * 1000);
continue;
}
g_sync_idle = 0;
/* 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;
}
static void load_autobackup(void)
{
FILE *f = fopen(AUTOBK_PATH, "r");
if (!f)
return;
int v = 0;
if (fscanf(f, "%d", &v) == 1)
g_autobackup = (v != 0);
fclose(f);
}
static void save_autobackup(void)
{
FILE *f = fopen(AUTOBK_PATH, "w");
if (f) {
fputc(g_autobackup ? '1' : '0', f);
fclose(f);
}
}
/* ------------------------------------------------------------------ */
/* 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, float bw, float bh,
int d, unsigned int frame)
{
struct disp_item *it = &g_disp[d];
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;
/* Immich cloud status icons (the bundled SVGs): cloud = on server only,
* cloud-off = on device only, cloud-done = on both. White, bottom-right
* of the cell, no background; a 1px dark shadow keeps it legible. */
vita2d_texture *ic = (kind == 4) ? g_ic_both :
(kind == 1) ? g_ic_server : g_ic_device;
if (!ic)
return;
const float isz = 26.0f;
float ix = bx + bw - isz - 4; /* bottom-right corner */
float iy = by + bh - isz - 4;
float s = isz / vita2d_texture_get_width(ic);
uint32_t fg = (kind == 5) ? RGBA8(235, 90, 90, 255)
: RGBA8(255, 255, 255, 255);
vita2d_draw_texture_tint_scale(ic, ix + 1, iy + 1, s, s,
RGBA8(0, 0, 0, 140)); /* shadow */
vita2d_draw_texture_tint_scale(ic, ix, iy, s, s, fg);
}
/* 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;
}
+329
View File
@@ -0,0 +1,329 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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();
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);
/* build auth per request so an account switch (new token)
* takes effect immediately */
char keyhdr[300];
auth_header(keyhdr, sizeof(keyhdr));
struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_RESOLVE, g_resolve_list);
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);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, NULL);
curl_slist_free_all(hdrs);
}
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;
}
/* bilinear, not nearest: thumbs get blown up in the detail view and
* full-res photos get scaled down to the screen */
if (tex)
vita2d_texture_set_filters(tex, SCE_GXM_TEXTURE_FILTER_LINEAR,
SCE_GXM_TEXTURE_FILTER_LINEAR);
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);
}
+991
View File
@@ -0,0 +1,991 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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);
}
/* ---- Immich-style top search bar ----------------------------------- */
/* an anti-aliased filled rounded rectangle, baked once into a white alpha
* mask texture (4x4 supersampled corner coverage) and drawn tinted to the
* wanted colour. vita2d has no AA primitives, so the coverage mask + the
* texture's bilinear sampling are what soften the corners. */
static vita2d_texture *rounded_mask_tex(int w, int h, float r)
{
enum { NSLOT = 14 }; /* a small fixed set of shapes (pill, buttons, ...) */
static vita2d_texture *cache[NSLOT];
static int cw[NSLOT], ch[NSLOT];
static float cr[NSLOT];
static int n;
for (int i = 0; i < n; i++)
if (cw[i] == w && ch[i] == h && cr[i] == r)
return cache[i];
if (n >= NSLOT)
return NULL;
vita2d_texture *t = vita2d_create_empty_texture_format(w, h,
SCE_GXM_TEXTURE_FORMAT_A8B8G8R8);
if (!t)
return NULL;
uint32_t *data = vita2d_texture_get_datap(t);
int stride = (int)(vita2d_texture_get_stride(t) / 4); /* pixels/row */
memset(data, 0, (size_t)stride * h * 4); /* keep row padding transparent */
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int hits = 0;
for (int sy = 0; sy < 4; sy++)
for (int sx = 0; sx < 4; sx++) {
float px = x + (sx + 0.5f) / 4.0f;
float py = y + (sy + 0.5f) / 4.0f;
/* nearest point of the corner-centre
* rectangle inset by r */
float qx = px < r ? r :
(px > w - r ? w - r : px);
float qy = py < r ? r :
(py > h - r ? h - r : py);
float dx = px - qx, dy = py - qy;
if (dx * dx + dy * dy <= r * r)
hits++;
}
data[y * stride + x] = RGBA8(255, 255, 255,
hits * 255 / 16);
}
}
cache[n] = t;
cw[n] = w;
ch[n] = h;
cr[n] = r;
n++;
return t;
}
/* ---- AA glyph icons (magnifier / map pin / cloud) -------------------- */
/* each is a coverage predicate over icon-local pixel coords (0..s); bake_icon
* supersamples it 4x4 into a white alpha mask, drawn tinted to the wanted
* colour. one cached texture per icon (called at a fixed size each frame). */
static float seg_dist(float px, float py, float ax, float ay,
float bx, float by)
{
float dx = bx - ax, dy = by - ay;
float l2 = dx * dx + dy * dy;
float t = l2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
if (t < 0) t = 0;
if (t > 1) t = 1;
float qx = ax + dx * t, qy = ay + dy * t;
return sqrtf((px - qx) * (px - qx) + (py - qy) * (py - qy));
}
static int in_tri(float px, float py, float ax, float ay, float bx, float by,
float cx, float cy)
{
float d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by);
float d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy);
float d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
int neg = (d1 < 0) || (d2 < 0) || (d3 < 0);
int pos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(neg && pos);
}
/* shared glyph line thickness (pixels), so every outline reads the same */
#define GLYPH_W 3.0f
/* a thin ring outline with a straight, round-capped handle off the lower right */
static int cov_search(float px, float py, int s)
{
float R = s * 0.30f, W = GLYPH_W, hw = W * 0.5f, c45 = 0.70710678f;
float ccx = s * 0.40f, ccy = s * 0.40f;
float dr = sqrtf((px - ccx) * (px - ccx) + (py - ccy) * (py - ccy));
int ring = (dr <= R && dr >= R - W);
float ax = ccx + c45 * (R - hw), ay = ccy + c45 * (R - hw);
float bx = ccx + c45 * (R + s * 0.42f), by = ccy + c45 * (R + s * 0.42f);
return ring || seg_dist(px, py, ax, ay, bx, by) <= hw;
}
/* a filled map marker: a round head (with a hole) tapering to a point */
static int cov_map(float px, float py, int s)
{
float cx = s * 0.5f, hcy = s * 0.40f, Rp = s * 0.30f, tipY = s * 0.95f;
float dHead = sqrtf((px - cx) * (px - cx) + (py - hcy) * (py - hcy));
int solid = (dHead <= Rp) ||
in_tri(px, py, cx - Rp * 0.78f, hcy + Rp * 0.30f,
cx + Rp * 0.78f, hcy + Rp * 0.30f, cx, tipY);
return solid && dHead >= Rp * 0.42f; /* punch the hole */
}
/* a filled cloud: three lobes tangent to a flat bottom (rounded sides), with
* a small body rectangle filling between the side lobes. `k` erodes it
* uniformly (px) so the outline variant is `filled && !filled(k)`. */
static int cloud_filled(float px, float py, int s, float k)
{
float yb = s * 0.68f - k;
int mid = sqrtf((px - s * 0.50f) * (px - s * 0.50f) +
(py - s * 0.42f) * (py - s * 0.42f)) <= s * 0.22f - k;
int lp = sqrtf((px - s * 0.34f) * (px - s * 0.34f) +
(py - s * 0.52f) * (py - s * 0.52f)) <= s * 0.16f - k;
int rp = sqrtf((px - s * 0.66f) * (px - s * 0.66f) +
(py - s * 0.52f) * (py - s * 0.52f)) <= s * 0.16f - k;
int base = (px >= s * 0.34f + k && px <= s * 0.66f - k &&
py >= s * 0.52f + k && py <= yb);
return mid || lp || rp || base;
}
static int cov_cloud(float px, float py, int s)
{
return cloud_filled(px, py, s, 0.0f);
}
/* a cloud outline (the filled shape minus an eroded copy) */
static int cov_cloud_line(float px, float py, int s)
{
float w = s * 0.12f;
if (w < 2.0f) w = 2.0f;
return cloud_filled(px, py, s, 0.0f) && !cloud_filled(px, py, s, w);
}
static vita2d_texture *bake_icon(int s, int (*inside)(float, float, int))
{
vita2d_texture *t = vita2d_create_empty_texture_format(s, s,
SCE_GXM_TEXTURE_FORMAT_A8B8G8R8);
if (!t)
return NULL;
uint32_t *data = vita2d_texture_get_datap(t);
int stride = (int)(vita2d_texture_get_stride(t) / 4);
memset(data, 0, (size_t)stride * s * 4);
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++) {
int hits = 0;
for (int sy = 0; sy < 4; sy++)
for (int sx = 0; sx < 4; sx++)
hits += inside(x + (sx + 0.5f) / 4.0f,
y + (sy + 0.5f) / 4.0f, s);
data[y * stride + x] = RGBA8(255, 255, 255,
hits * 255 / 16);
}
return t;
}
/* PlayStation triangle button glyph: an up-pointing outline, fixed-width edges */
static int cov_tri(float px, float py, int s)
{
float ax = s * 0.5f, ay = s * 0.10f; /* lifted up a bit */
float bx = s * 0.14f, by = s * 0.80f;
float cx = s * 0.86f, cy = s * 0.80f;
if (!in_tri(px, py, ax, ay, bx, by, cx, cy))
return 0;
float d = fminf(seg_dist(px, py, ax, ay, bx, by),
fminf(seg_dist(px, py, bx, by, cx, cy),
seg_dist(px, py, cx, cy, ax, ay)));
return d <= GLYPH_W; /* band just inside the edges */
}
/* PlayStation square button glyph: an outline square, fixed-width border */
static int cov_sq(float px, float py, int s)
{
float lo = s * 0.18f, hi = s * 0.82f;
int outer = px >= lo && px <= hi && py >= lo && py <= hi;
int inner = px >= lo + GLYPH_W && px <= hi - GLYPH_W &&
py >= lo + GLYPH_W && py <= hi - GLYPH_W;
return outer && !inner;
}
/* PlayStation cross button glyph: a thick X — two rotated rectangles, so the
* arm ends are flat and perpendicular (sharp), not box-clipped or rounded. */
static int cov_cross(float px, float py, int s)
{
float hw = GLYPH_W * 0.5f; /* half stroke */
float half = s * 0.30f; /* arm half-length */
float dx = px - s * 0.5f, dy = py - s * 0.5f;
float a = (dx + dy) * 0.70710678f; /* along one diagonal */
float b = (dx - dy) * 0.70710678f; /* along the other */
return (fabsf(b) <= hw && fabsf(a) <= half) ||
(fabsf(a) <= hw && fabsf(b) <= half);
}
/* PlayStation circle button glyph: an outline ring, fixed-width stroke */
static int cov_circle(float px, float py, int s)
{
float cx = s * 0.5f, cy = s * 0.5f, R = s * 0.38f, W = GLYPH_W;
float d = sqrtf((px - cx) * (px - cx) + (py - cy) * (py - cy));
return d <= R && d >= R - W;
}
/* a filled right-pointing play triangle */
static int cov_play(float px, float py, int s)
{
return in_tri(px, py, s * 0.36f, s * 0.27f,
s * 0.36f, s * 0.73f, s * 0.76f, s * 0.5f);
}
/* a photo-stack glyph (albums): an outlined photo with a small mountain and
* sun, in front of the top/right edges of a second photo offset behind it */
static int cov_album(float px, float py, int s)
{
float W = GLYPH_W;
float fx0 = s * 0.10f, fy0 = s * 0.30f, fx1 = s * 0.76f, fy1 = s * 0.88f;
int in_front = px >= fx0 && px <= fx1 && py >= fy0 && py <= fy1;
int front_line = in_front && !(px >= fx0 + W && px <= fx1 - W &&
py >= fy0 + W && py <= fy1 - W);
int mountain = in_tri(px, py, fx0 + W, fy1 - W, s * 0.36f, s * 0.56f,
s * 0.62f, fy1 - W);
float sx = s * 0.57f, sy = s * 0.45f, sr = s * 0.065f;
int sun = (px - sx) * (px - sx) + (py - sy) * (py - sy) <= sr * sr;
float bx0 = s * 0.24f, by0 = s * 0.12f, bx1 = s * 0.92f, by1 = s * 0.72f;
int back_line = ((py >= by0 && py <= by0 + W && px >= bx0 && px <= bx1) ||
(px >= bx1 - W && px <= bx1 && py >= by0 && py <= by1)) &&
!(px >= fx0 - 2.0f && px <= fx1 + 2.0f &&
py >= fy0 - 2.0f && py <= fy1 + 2.0f);
return front_line || mountain || sun || back_line;
}
/* cache one texture per glyph; all are drawn at a fixed size each frame */
static vita2d_texture *icon_tex(int which, int s)
{
static vita2d_texture *cache[10];
static int cs[10];
if (which < 0 || which > 9)
return NULL;
if (cache[which] && cs[which] == s)
return cache[which];
if (cache[which]) {
vita2d_wait_rendering_done();
vita2d_free_texture(cache[which]);
cache[which] = NULL;
}
int (*fn)(float, float, int) = which == 0 ? cov_search :
which == 1 ? cov_map :
which == 2 ? cov_cloud :
which == 3 ? cov_tri :
which == 4 ? cov_sq :
which == 5 ? cov_cross :
which == 6 ? cov_circle :
which == 7 ? cov_play :
which == 8 ? cov_cloud_line : cov_album;
cache[which] = bake_icon(s, fn);
cs[which] = s;
return cache[which];
}
#define ICON_SEARCH 0
#define ICON_MAP 1
#define ICON_CLOUD 2
#define ICON_TRI 3
#define ICON_SQUARE 4
#define ICON_CROSS 5
#define ICON_CIRCLE 6
#define ICON_PLAY 7
#define ICON_CLOUD_LINE 8
#define ICON_ALBUM 9
/* a DualShock-style face button: a dark disc (subtle lighter rim) centred at
* (cx,cy) with the coloured glyph on top. each glyph keeps a per-call size. */
/* an anti-aliased filled disc baked into a white alpha mask (4x4 supersampled),
* drawn tinted; replaces aliased vita2d_draw_fill_circle for UI discs */
static vita2d_texture *disc_tex(int d)
{
/* keyed by size, no eviction: must be large enough to hold the fixed UI
* disc sizes (face buttons) plus the many cluster-bubble sizes, else a
* button drawn after the bubbles falls back to the aliased fill_circle */
enum { NDISC = 24 };
static vita2d_texture *cache[NDISC];
static int cd[NDISC], n;
for (int i = 0; i < n; i++)
if (cd[i] == d)
return cache[i];
if (n >= NDISC)
return NULL;
vita2d_texture *t = vita2d_create_empty_texture_format(d, d,
SCE_GXM_TEXTURE_FORMAT_A8B8G8R8);
if (!t)
return NULL;
uint32_t *data = vita2d_texture_get_datap(t);
int stride = (int)(vita2d_texture_get_stride(t) / 4);
memset(data, 0, (size_t)stride * d * 4);
float c = d / 2.0f, r = d / 2.0f;
for (int y = 0; y < d; y++)
for (int x = 0; x < d; x++) {
int hits = 0;
for (int sy = 0; sy < 4; sy++)
for (int sx = 0; sx < 4; sx++) {
float dx = x + (sx + 0.5f) / 4.0f - c;
float dy = y + (sy + 0.5f) / 4.0f - c;
if (dx * dx + dy * dy <= r * r)
hits++;
}
data[y * stride + x] = RGBA8(255, 255, 255, hits * 255 / 16);
}
cache[n] = t;
cd[n] = d;
n++;
return t;
}
/* a filled (AA) disc at (cx,cy), tinted */
static void draw_disc(float cx, float cy, float d, uint32_t col)
{
vita2d_texture *t = disc_tex((int)(d + 0.5f));
if (t)
vita2d_draw_texture_tint(t, cx - (int)(d + 0.5f) / 2.0f,
cy - (int)(d + 0.5f) / 2.0f, col);
else
vita2d_draw_fill_circle(cx, cy, d / 2.0f, col);
}
static void draw_ps_button(float cx, float cy, float d, int icon,
uint32_t tint, int gs)
{
draw_disc(cx, cy, d, RGBA8(46, 48, 56, 255)); /* flat dark face */
vita2d_texture *g = icon_tex(icon, gs);
if (g)
vita2d_draw_texture_tint(g, cx - gs / 2.0f, cy - gs / 2.0f, tint);
}
/* pixel geometry of the search pill + the three buttons on its right
* (albums, map, cloud), shared by the drawer and the tap test */
#define BAR_M 12.0f /* left/right margin */
#define BAR_Y 8.0f /* top of the pill */
#define BAR_H 40.0f /* pill height */
#define BAR_R 12.0f /* corner radius */
#define BTN_SZ 40.0f /* button (rounded square) size */
#define BTN_GAP 10.0f /* gap between the buttons */
#define BTN2_CX ((float)SCREEN_W - BAR_M - BTN_SZ / 2) /* cloud (rightmost) */
#define BTN1_CX (BTN2_CX - BTN_SZ - BTN_GAP) /* map */
#define BTN0_CX (BTN1_CX - BTN_SZ - BTN_GAP) /* albums */
#define BTN_CY (BAR_Y + BAR_H / 2)
#define BAR_PX (BAR_M + 42.0f) /* pill left x (logo to its left) */
#define BAR_W (BTN0_CX - BTN_SZ / 2 - BTN_GAP - BAR_PX) /* pill width */
#define BAR_CLEAR_CX (BAR_PX + BAR_W - 22.0f) /* centre of the clear chip */
/* one future-feature button: rounded-square background + a centred AA glyph */
static void draw_bar_button(float cx, float cy, int icon)
{
vita2d_texture *bg = rounded_mask_tex((int)BTN_SZ, (int)BTN_SZ, 11.0f);
if (bg)
vita2d_draw_texture_tint(bg, cx - BTN_SZ / 2, cy - BTN_SZ / 2,
RGBA8(46, 46, 52, 255));
int isz = 26;
vita2d_texture *g = icon_tex(icon, isz);
if (g)
vita2d_draw_texture_tint(g, cx - isz / 2.0f, cy - isz / 2.0f,
RGBA8(190, 190, 196, 255));
}
/* draw the bar shifted vertically by `yoff` (Square slides it out of view);
* `focus` highlights the focused element (1=field, 2=albums, 3=map, 4=cloud) */
static void draw_search_bar(float yoff, int focus)
{
unsigned int field = RGBA8(38, 38, 42, 255);
unsigned int hi = RGBA8(94, 110, 215, 255); /* focus ring (indigo) */
float by = BAR_Y + yoff, cy = BTN_CY + yoff;
/* opaque strip so grid items scroll cleanly underneath the bar */
vita2d_draw_rectangle(0, yoff, SCREEN_W, SEARCH_H, RGBA8(16, 16, 16, 255));
/* Immich logo to the left of the search field */
if (g_logo) {
float ls = (BAR_H - 6.0f) / vita2d_texture_get_width(g_logo);
vita2d_draw_texture_scale(g_logo, BAR_M, by + 3.0f, ls, ls);
}
/* focus ring behind the field */
if (focus == 1) {
vita2d_texture *ring = rounded_mask_tex((int)BAR_W + 6,
(int)BAR_H + 6, BAR_R + 3);
if (ring)
vita2d_draw_texture_tint(ring, BAR_PX - 3, by - 3, hi);
}
/* borderless field: an AA rounded-rect mask tinted to the field colour */
vita2d_texture *mask = rounded_mask_tex((int)BAR_W, (int)BAR_H, BAR_R);
if (mask)
vita2d_draw_texture_tint(mask, BAR_PX, by, field);
else
vita2d_draw_rectangle(BAR_PX, by, BAR_W, BAR_H, field);
int isz = 24;
vita2d_texture *icon = icon_tex(ICON_SEARCH, isz);
if (icon)
vita2d_draw_texture_tint(icon, BAR_PX + 22 - isz / 2.0f,
cy - isz / 2.0f,
RGBA8(190, 190, 196, 255));
/* focus rings behind the buttons */
if (focus >= 2 && focus <= 4) {
vita2d_texture *r = rounded_mask_tex((int)BTN_SZ + 6,
(int)BTN_SZ + 6, 14.0f);
float fcx = (focus == 2) ? BTN0_CX :
(focus == 3) ? BTN1_CX : BTN2_CX;
if (r)
vita2d_draw_texture_tint(r, fcx - (BTN_SZ + 6) / 2,
cy - (BTN_SZ + 6) / 2, hi);
}
draw_bar_button(BTN0_CX, cy, ICON_ALBUM);
draw_bar_button(BTN1_CX, cy, ICON_MAP);
draw_bar_button(BTN2_CX, cy, ICON_CLOUD);
float tx = BAR_PX + 44, ty = by + BAR_H - 13;
if (g_album_active && g_album_open >= 0 && g_album_open < g_album_len) {
/* inside an album: its name, and the O chip to go back */
char q[80];
snprintf(q, sizeof(q), "%.48s", g_album_name[g_album_open][0] ?
g_album_name[g_album_open] : "(untitled album)");
draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q);
int gs = 26;
vita2d_texture *og = icon_tex(ICON_CIRCLE, gs);
if (og)
vita2d_draw_texture_tint(og, BAR_CLEAR_CX - gs / 2.0f,
cy - gs / 2.0f,
RGBA8(235, 90, 85, 255));
} else if (g_search_active && g_search_query[0]) {
char q[64];
snprintf(q, sizeof(q), "%.40s", g_search_query);
draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q);
/* press O (or tap here) to clear: just the red ring glyph, no
* button disc/border on the pill */
{
int gs = 26;
vita2d_texture *og = icon_tex(ICON_CIRCLE, gs);
if (og)
vita2d_draw_texture_tint(og,
BAR_CLEAR_CX - gs / 2.0f, cy - gs / 2.0f,
RGBA8(235, 90, 85, 255));
}
} else {
draw_text(tx, ty, RGBA8(140, 140, 148, 255), 0.95f,
"Search your photos");
}
}
/* minimal UTF-8 <-> UTF-16 (BMP) for the on-screen keyboard buffers */
static void utf8_to_utf16(const char *s, uint16_t *out, int outcap)
{
int n = 0;
while (*s && n < outcap - 1) {
unsigned char c = (unsigned char)s[0];
unsigned int cp;
if (c < 0x80) {
cp = c; s += 1;
} else if ((c >> 5) == 0x6 && (s[1] & 0xC0) == 0x80) {
cp = ((c & 0x1F) << 6) | (s[1] & 0x3F); s += 2;
} else if ((c >> 4) == 0xE && (s[1] & 0xC0) == 0x80 &&
(s[2] & 0xC0) == 0x80) {
cp = ((c & 0x0F) << 12) | ((s[1] & 0x3F) << 6) |
(s[2] & 0x3F); s += 3;
} else {
cp = '?'; s += 1;
}
out[n++] = (uint16_t)(cp > 0xFFFF ? '?' : cp);
}
out[n] = 0;
}
static void utf16_to_utf8(const uint16_t *s, char *out, int outcap)
{
int n = 0;
for (; *s; s++) {
unsigned int cp = *s;
if (cp < 0x80) {
if (n + 1 >= outcap) break;
out[n++] = (char)cp;
} else if (cp < 0x800) {
if (n + 2 >= outcap) break;
out[n++] = (char)(0xC0 | (cp >> 6));
out[n++] = (char)(0x80 | (cp & 0x3F));
} else {
if (n + 3 >= outcap) break;
out[n++] = (char)(0xE0 | (cp >> 12));
out[n++] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[n++] = (char)(0x80 | (cp & 0x3F));
}
}
out[n] = '\0';
}
/* bring up the system on-screen keyboard (a common dialog). returns 1 and
* fills `out` (UTF-8) when the user confirms, 0 if they cancelled. blocks,
* driving the dialog each frame; the worker thread keeps running, which is
* fine since we touch no shared arrays here. */
static int ime_input(const char *title, const char *initial,
char *out, size_t outsz, int password)
{
static uint16_t title16[SCE_IME_DIALOG_MAX_TITLE_LENGTH];
static uint16_t init16[129];
static uint16_t buf16[129];
utf8_to_utf16(title, title16, SCE_IME_DIALOG_MAX_TITLE_LENGTH);
utf8_to_utf16(initial ? initial : "", init16, 129);
memset(buf16, 0, sizeof(buf16));
SceImeDialogParam p;
sceImeDialogParamInit(&p);
p.supportedLanguages = 0; /* allow all installed languages */
p.languagesForced = 0;
p.type = SCE_IME_TYPE_DEFAULT;
p.option = SCE_IME_OPTION_NO_AUTO_CAPITALIZATION;
p.dialogMode = SCE_IME_DIALOG_DIALOG_MODE_WITH_CANCEL;
p.textBoxMode = password ? SCE_IME_DIALOG_TEXTBOX_MODE_PASSWORD :
SCE_IME_DIALOG_TEXTBOX_MODE_WITH_CLEAR;
p.title = title16;
p.maxTextLength = 128;
p.initialText = init16;
p.inputTextBuffer = buf16;
p.enterLabel = SCE_IME_ENTER_LABEL_SEARCH;
if (sceImeDialogInit(&p) < 0)
return 0;
int confirmed = 0;
for (;;) {
vita2d_start_drawing();
vita2d_clear_screen();
vita2d_end_drawing();
vita2d_common_dialog_update();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
if (sceImeDialogGetStatus() != SCE_COMMON_DIALOG_STATUS_FINISHED)
continue;
SceImeDialogResult res;
memset(&res, 0, sizeof(res));
sceImeDialogGetResult(&res);
if (res.button == SCE_IME_DIALOG_BUTTON_ENTER) {
utf16_to_utf8(buf16, out, (int)outsz);
confirmed = 1;
}
sceImeDialogTerm();
break;
}
return confirmed;
}
static void draw_hud(const char *text); /* defined below */
/* persist server + credentials (and the loader's other settings) to config */
static void save_config(void)
{
FILE *f = fopen(CONFIG_PATH, "w");
if (!f)
return;
fprintf(f, "server=%s\n", g_server);
fprintf(f, "apikey=%s\n", g_apikey);
if (g_email[0]) fprintf(f, "email=%s\n", g_email);
if (g_password[0]) fprintf(f, "password=%s\n", g_password);
if (g_serverip[0]) fprintf(f, "serverip=%s\n", g_serverip);
for (int i = 0; i < g_syncdir_count; i++)
fprintf(f, "syncdir=%s\n", g_syncdirs[i]);
if (g_syncmax_mb != 512)
fprintf(f, "syncmaxmb=%ld\n", g_syncmax_mb);
fclose(f);
}
/* give g_server an https:// scheme if it lacks one, strip a trailing slash */
static void normalize_server(void)
{
size_t n = strlen(g_server);
while (n > 0 && g_server[n - 1] == '/')
g_server[--n] = '\0';
if (g_server[0] && !strstr(g_server, "://")) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "https://%s", g_server);
snprintf(g_server, sizeof(g_server), "%s", tmp);
}
}
/* interactive sign-in: server + email + password via the on-screen keyboard.
* blocks until a login succeeds, then saves the config. */
static void login_screen(void)
{
int sel = 0; /* 0 server, 1 email, 2 password, 3 log in, 4 demo */
char errmsg[160] = "";
unsigned int prev = 0, frame = 0;
/* field / button geometry, shared by the touch hit-test and the drawer */
const float fx = 180, fw = 600, fh = 44, row0 = 200, rowgap = 58;
const float by = row0 + 3 * rowgap + 6; /* Log in button */
const float dy = by + fh + 12; /* Try demo button */
int lt_down = 0, lt_drag = 0;
float lt_x = 0, lt_y = 0, lt_sx = 0, lt_sy = 0;
for (;;) {
SceCtrlData pad;
sceCtrlPeekBufferPositive(0, &pad, 1);
unsigned int pressed = pad.buttons & ~prev;
prev = pad.buttons;
frame++;
if (pressed & SCE_CTRL_UP) sel = (sel + 4) % 5;
if (pressed & SCE_CTRL_DOWN) sel = (sel + 1) % 5;
/* activate an item with X, or by tapping it on the touchscreen */
int activate = (pressed & SCE_CTRL_CROSS) ? sel : -1;
{
SceTouchData td;
sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1);
if (td.reportNum > 0) {
float tx = td.report[0].x * 0.5f;
float ty = td.report[0].y * 0.5f;
if (!lt_down) {
lt_down = 1;
lt_drag = 0;
lt_sx = tx;
lt_sy = ty;
} else if (fabsf(tx - lt_sx) > 14 ||
fabsf(ty - lt_sy) > 14) {
lt_drag = 1;
}
lt_x = tx;
lt_y = ty;
} else if (lt_down) {
lt_down = 0;
if (!lt_drag && lt_x >= fx && lt_x <= fx + fw) {
for (int i = 0; i < 3; i++) {
float fy = row0 + i * rowgap;
if (lt_y >= fy && lt_y <= fy + fh) {
sel = i;
activate = i;
}
}
if (lt_y >= by && lt_y <= by + fh) {
sel = 3;
activate = 3;
} else if (lt_y >= dy && lt_y <= dy + fh) {
sel = 4;
activate = 4;
}
}
}
}
if (activate >= 0) {
if (activate == 0) {
ime_input("Server URL", g_server,
g_server, sizeof(g_server), 0);
normalize_server();
} else if (activate == 1) {
ime_input("Email", g_email, g_email,
sizeof(g_email), 0);
} else if (activate == 2) {
ime_input("Password", g_password, g_password,
sizeof(g_password), 1);
} else {
/* activate 4 = Try demo: prefill the public demo */
if (activate == 4) {
snprintf(g_server, sizeof(g_server),
"https://demo.immich.app");
snprintf(g_email, sizeof(g_email),
"demo@immich.app");
snprintf(g_password, sizeof(g_password),
"demo");
}
g_token[0] = g_apikey[0] = '\0';
normalize_server();
if (!g_server[0] || !g_email[0] || !g_password[0]) {
snprintf(errmsg, sizeof(errmsg),
"Enter server, email and password");
} else {
draw_loading("Signing in...", frame);
char lerr[120];
if (do_login(lerr, sizeof(lerr)) == 0) {
save_config();
return;
}
snprintf(errmsg, sizeof(errmsg),
"Login failed: %.100s", lerr);
}
}
prev = 0xFFFFFFFF; /* swallow buttons held in the dialog */
lt_down = 0;
continue;
}
vita2d_start_drawing();
vita2d_clear_screen();
if (g_logo) {
float lw = vita2d_texture_get_width(g_logo);
float s = 72.0f / lw;
vita2d_draw_texture_scale(g_logo, SCREEN_W / 2.0f - 36.0f,
44.0f, s, s);
}
draw_centered(150, RGBA8(255, 255, 255, 255), "Sign in to Immich");
const char *labels[3] = { "Server", "Email", "Password" };
char pwmask[40];
int pl = (int)strlen(g_password);
if (pl > 24) pl = 24;
memset(pwmask, '*', pl);
pwmask[pl] = '\0';
const char *vals[3];
vals[0] = g_server[0] ? g_server : "";
vals[1] = g_email[0] ? g_email : "";
vals[2] = g_password[0] ? pwmask : "";
for (int i = 0; i < 3; i++) {
float fy = row0 + i * rowgap;
if (sel == i) {
vita2d_texture *r = rounded_mask_tex(
(int)fw + 6, (int)fh + 6, 13.0f);
if (r)
vita2d_draw_texture_tint(r, fx - 3, fy - 3,
RGBA8(94, 110, 215, 255));
}
vita2d_texture *bx = rounded_mask_tex((int)fw, (int)fh, 10.0f);
if (bx)
vita2d_draw_texture_tint(bx, fx, fy,
RGBA8(38, 38, 42, 255));
draw_text(fx + 14, fy + 16, RGBA8(150, 150, 158, 255),
0.75f, labels[i]);
draw_text(fx + 14, fy + 38, RGBA8(230, 230, 235, 255),
0.95f, vals[i]);
}
if (sel == 3) {
vita2d_texture *r = rounded_mask_tex((int)fw + 6,
(int)fh + 6, 13.0f);
if (r)
vita2d_draw_texture_tint(r, fx - 3, by - 3,
RGBA8(120, 140, 235, 255));
}
vita2d_texture *lb = rounded_mask_tex((int)fw, (int)fh, 10.0f);
if (lb)
vita2d_draw_texture_tint(lb, fx, by,
RGBA8(94, 110, 215, 255));
draw_centered((int)by + 30, RGBA8(245, 245, 250, 255), "Log in");
/* Try demo button */
if (sel == 4) {
vita2d_texture *r = rounded_mask_tex((int)fw + 6,
(int)fh + 6, 13.0f);
if (r)
vita2d_draw_texture_tint(r, fx - 3, dy - 3,
RGBA8(120, 140, 235, 255));
}
vita2d_texture *dbn = rounded_mask_tex((int)fw, (int)fh, 10.0f);
if (dbn)
vita2d_draw_texture_tint(dbn, fx, dy, RGBA8(50, 52, 60, 255));
draw_centered((int)dy + 30, RGBA8(210, 210, 218, 255),
"Try demo");
if (errmsg[0])
draw_centered(SCREEN_H - 46, RGBA8(220, 120, 120, 255),
errmsg);
draw_hud("Up/Down select X edit / log in");
vita2d_end_drawing();
vita2d_swap_buffers();
sceDisplayWaitVblankStart();
}
}
/* run an Immich smart (CLIP) search and switch the grid to its results.
* main thread only: it appends any newly-seen assets to the g_asset_* arrays
* (like fetch_page) and rebuilds g_disp from the result set. returns the
* number of distinct results shown (>=0), or -1 on a request/parse failure. */
static int run_smart_search(const char *query)
{
char esc[256]; /* JSON-escape the query into the request body */
int e = 0;
for (const char *p = query; *p && e < (int)sizeof(esc) - 2; p++) {
unsigned char c = (unsigned char)*p;
if (c == '"' || c == '\\') {
esc[e++] = '\\';
esc[e++] = (char)c;
} else if (c >= 0x20) {
esc[e++] = (char)c;
} else {
esc[e++] = ' ';
}
}
esc[e] = '\0';
char url[600];
snprintf(url, sizeof(url), "%s/api/search/smart", g_server);
char body[320];
/* withExif so results carry width/height — without it every asset has
* no aspect ratio and the justified grid collapses to uniform columns */
snprintf(body, sizeof(body), "{\"query\":\"%s\",\"withExif\":true}", esc);
membuf buf;
long code;
CURLcode res = http_request(url, body, &buf, &code, NULL, 0);
if (res != CURLE_OK || code < 200 || code >= 300) {
log_line("smart search: curl %d http %ld: %.160s", res, code,
buf.data ? buf.data : "");
free(buf.data);
return -1;
}
char err[160];
int before = g_asset_count;
int added = parse_assets(buf.data, buf.size, err, sizeof(err));
free(buf.data);
if (added < 0) {
log_line("smart search parse: %s", err);
return -1;
}
/* the freshly-parsed tail [before, count) is the result set in server
* order. capture the ids before dedup compacts that tail around, then
* resolve each id to its surviving index in g_asset_*. */
int raw = g_asset_count - before;
static char (*ids)[40];
static int ids_cap;
if (raw > ids_cap) {
void *np = realloc(ids, sizeof(ids[0]) * raw);
if (!np)
return -1;
ids = np;
ids_cap = raw;
}
for (int i = 0; i < raw; i++)
memcpy(ids[i], g_asset_ids[before + i], sizeof(ids[0]));
dedup_new_assets(before);
if (!(g_search_cap = GROW(g_search_idx, g_search_cap,
raw > 0 ? raw : 1)))
return -1;
g_search_count = 0;
for (int i = 0; i < raw; i++) {
int idx = -1;
for (int j = 0; j < g_asset_count; j++)
if (!strcmp(g_asset_ids[j], ids[i])) {
idx = j;
break;
}
if (idx < 0)
continue;
int seen = 0;
for (int k = 0; k < g_search_count && !seen; k++)
seen = (g_search_idx[k] == idx);
if (!seen)
g_search_idx[g_search_count++] = idx;
}
g_search_active = 1;
snprintf(g_search_query, sizeof(g_search_query), "%s", query);
rebuild_display();
log_line("smart search '%s': %d result(s)", query, g_search_count);
return g_search_count;
}
/* run the (blocking) smart search off the main thread so the grid can animate
* a throbber while the request is in flight. the main thread only draws the
* throbber meanwhile, so it never touches the display model this rebuilds. */
static int search_thread(SceSize args, void *argp)
{
(void)args; (void)argp;
int r = run_smart_search(g_search_pending);
__sync_synchronize();
g_search_result = r;
g_search_done = 1;
return 0;
}
static void draw_hud(const char *text)
{
(void)text; /* bottom HUD bar removed */
}
/* spinning throbber: a ring of dots with a brightness tail */
static void draw_throbber(float cx, float cy, float r, unsigned int frame)
{
for (int i = 0; i < 12; i++) {
float a = (float)i * (6.2831853f / 12.0f);
int phase = ((int)(frame / 3) - i) % 12;
if (phase < 0)
phase += 12;
unsigned char al = (unsigned char)(255 - phase * 18);
vita2d_draw_fill_circle(cx + cosf(a) * r, cy + sinf(a) * r,
r / 5.5f,
RGBA8(255, 255, 255, al));
}
}
/* grey panel (with a throbber) for a photo that hasn't loaded yet (mid-swipe,
* or in the static view before even the low-res preview is in); sized to the
* photo's fitted rect (from its aspect ratio) and offset horizontally by
* `xoff`, so it matches where the real image lands */
static void draw_photo_placeholder(float xoff, int d, unsigned int frame)
{
float ratio = disp_ratio(d); /* w/h */
float w = SCREEN_W, h = SCREEN_W / ratio;
if (h > SCREEN_H) {
h = SCREEN_H;
w = SCREEN_H * ratio;
}
float x = (SCREEN_W - w) / 2.0f + xoff;
float y = (SCREEN_H - h) / 2.0f;
vita2d_draw_rectangle(x, y, w, h, RGBA8(38, 38, 44, 255));
draw_throbber(x + w / 2.0f, y + h / 2.0f, 22.0f, frame);
}
/* translucent play button over a video's poster in the detail view (AA) */
static void draw_play_overlay(void)
{
float cx = SCREEN_W / 2.0f, cy = SCREEN_H / 2.0f;
draw_disc(cx, cy, 92.0f, RGBA8(20, 20, 20, 150));
int gs = 56;
vita2d_texture *p = icon_tex(ICON_PLAY, gs);
if (p)
vita2d_draw_texture_tint(p, cx - gs / 2.0f, cy - gs / 2.0f,
RGBA8(255, 255, 255, 235));
}
static void draw_error_detail(int idx)
{
draw_centered(220, RGBA8(255, 80, 80, 255), "Failed to load this photo");
draw_text(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);
draw_text(40, y, RGBA8(200, 200, 200, 255), 1.0f, line);
}
}
+993
View File
@@ -0,0 +1,993 @@
/* vitaImmich unity build — part of one translation unit.
* This file is #included by src/main.c (in order) and shares all
* file-scope state with the other parts; it is NOT compiled on its own.
* Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag
* undefined symbols here because of that — those are expected.
*/
/* ------------------------------------------------------------------ */
/* 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))
/* the server-side fix for a too-high-bitrate clip, appended to the relevant
* error messages so the user knows what to change on their Immich install */
#define SERVER_FIX_STEPS \
"To fix it on the server: in Immich open Administration -> Settings -> " \
"Video Transcoding, set Transcode policy to \"All videos\" and Max " \
"bitrate to 10000k (or Target resolution to 720p), save, then run the " \
"\"Transcode video\" job under Administration -> Jobs."
/* draw an error screen until O is pressed. title and detail are word-wrapped
* to the screen, so long messages no longer run off the edges. */
static void show_blocking_error(const char *title, const char *detail)
{
SceCtrlData pad;
do {
sceCtrlPeekBufferPositive(0, &pad, 1);
vita2d_start_drawing();
vita2d_clear_screen();
int y = draw_centered_wrapped(150, RGBA8(255, 80, 80, 255),
title, SCREEN_W - 80, 34);
if (detail && detail[0])
draw_centered_wrapped(y + 16, RGBA8(205, 205, 205, 255),
detail, SCREEN_W - 80, 30);
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;
/* poster/thumb drawn behind the download progress and the player's startup
* (the seconds AVPlayer spends parsing before the first frame); set by the
* play call sites, valid for the whole modal download + playback */
static vita2d_texture *g_dl_bg;
/* display rotation (degrees clockwise) of the video being played; from the
* asset's exif orientation, so portrait recordings play upright */
static int g_video_rot;
static int ori_to_deg(int o)
{
switch (o) {
case 3: case 4: return 180;
case 5: case 6: return 90;
case 7: case 8: return 270;
default: return 0;
}
}
/* fit a (possibly rotated) video frame to the screen */
static void draw_video_frame(vita2d_texture *tex)
{
float w = vita2d_texture_get_width(tex);
float h = vita2d_texture_get_height(tex);
if (g_video_rot == 0) {
draw_texture_fitted(tex, 0, 0, SCREEN_W, SCREEN_H);
return;
}
int side = (g_video_rot == 90 || g_video_rot == 270);
float ew = side ? h : w, eh = side ? w : h;
float sc = ((float)SCREEN_W / ew < (float)SCREEN_H / eh) ?
(float)SCREEN_W / ew : (float)SCREEN_H / eh;
vita2d_draw_texture_scale_rotate(tex, SCREEN_W / 2.0f, SCREEN_H / 2.0f,
sc, sc,
(float)g_video_rot *
(3.14159265f / 180.0f));
}
/* full-screen status with the video's poster behind it */
static void show_video_status(const char *text)
{
vita2d_start_drawing();
vita2d_clear_screen();
if (g_dl_bg) {
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
RGBA8(0, 0, 0, 130));
}
draw_centered(SCREEN_H / 2, RGBA8(255, 255, 255, 255), text);
vita2d_end_drawing();
vita2d_swap_buffers();
}
static int dl_progress_cb(void *ud, curl_off_t dltotal, curl_off_t dlnow,
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();
if (g_dl_bg) {
/* the video's poster behind the progress, dimmed */
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
RGBA8(0, 0, 0, 130));
}
draw_centered(SCREEN_H / 2 - 20, RGBA8(255, 255, 255, 255), buf);
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];
auth_header(keyhdr, sizeof(keyhdr));
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;
curl_off_t dl = 0;
if (res == CURLE_OK)
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
log_line("video dl %s: res=%d code=%ld bytes=%lld",
g_asset_ids[idx], res, code, (long long)dl);
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)
{
void *r = memalign(alignment, size);
/* the AVC decoder's working memory comes through here; a NULL or a big
* alloc is the prime suspect for the player dying right after parse */
if (!r || size >= 0x100000)
log_line("video: av_alloc %u align %u -> %p", size, alignment, r);
return r;
}
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);
int mr = sceGxmMapMemory(base, size,
SCE_GXM_MEMORY_ATTRIB_READ | SCE_GXM_MEMORY_ATTRIB_WRITE);
log_line("video: gpu_alloc %u bytes align %u -> %p (map 0x%08x)",
size, alignment, base, mr);
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;
static volatile int g_av_audio_frames; /* diag: audio frames pulled this play */
/* 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)) {
g_av_audio_frames++;
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_video_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;
g_av_audio_frames = 0;
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;
int waited = 0;
for (waited = 0; waited < 300 && !(active = sceAvPlayerIsActive(g_avp)); waited++)
show_video_status("Starting video...");
log_line("video: active=%d after %d polls", active, waited);
/* log every stream the demuxer found (codec/dims) — a video-only stream
* has just one entry, which is fine; this pinpoints decode failures */
{
SceAvPlayerStreamInfo si;
for (int s = 0; s < 4; s++) {
memset(&si, 0, sizeof(si));
if (sceAvPlayerGetStreamInfo(g_avp, s, &si) < 0)
break;
log_line("video: stream %d type=%u dur=%llu", s,
(unsigned)si.type, (unsigned long long)si.duration);
}
}
/* 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;
int frames = 0; /* decoded video frames seen this play */
int loops = 0; /* main-loop iterations */
int started = 0; /* a frame has been decoded (real playback began) */
/* AddSource is async: the player briefly flips active->inactive->active
* while it spins up its decoders, so an early IsActive==false is NOT the
* end of the stream. Don't treat it as EOS until either a frame has been
* decoded or this startup grace window has elapsed. */
uint64_t play_t0 = sceKernelGetProcessTimeWide();
int last_act = -1; /* diag: log IsActive transitions through the loop */
unsigned int prev = 0xffffffff; /* swallow the X press that got us here */
while (active) {
loops++;
{
int a = sceAvPlayerIsActive(g_avp);
if (a != last_act) {
log_line("video: IsActive=%d at loop %d t=%llums", a,
loops,
(unsigned long long)((sceKernelGetProcessTimeWide()
- play_t0) / 1000));
last_act = a;
}
}
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) &&
(started ||
sceKernelGetProcessTimeWide() - play_t0 > 8000000ULL)) {
ended_eos = 1;
break; /* end of stream (or never started within 8 s) */
}
if (sceAvPlayerGetVideoData(g_avp, &vframe[buf_idx])) {
if (frames == 0)
log_line("video: first frame %ux%u pData=%p",
vframe[buf_idx].details.video.width,
vframe[buf_idx].details.video.height,
vframe[buf_idx].pData);
frames++;
started = 1;
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_video_frame(cur);
else if (g_dl_bg)
/* poster until the first decoded frame arrives */
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
if (duration == 0) {
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();
}
log_line("video: loop exit frames=%d loops=%d eos=%d audioframes=%d",
frames, loops, ended_eos, g_av_audio_frames);
if (!active) {
log_line("video: player never became active");
show_blocking_error("Could not play this video",
"The player never started. The Vita decodes "
"H.264/AAC only; if this is HEVC, set Immich "
"Video Transcoding to convert it to H.264.");
} else if (frames == 0) {
/* the player parsed the file and went active, allocated its
* decoder buffers, then aborted without ever emitting a frame.
* On hardware this happens when the H.264 stream overruns the
* limits of the level declared in its SPS (e.g. a ~19 Mbps clip
* tagged Level 3.1, whose ceiling is 14 Mbps): the hardware
* decoder sizes its buffers from the declared level and rejects
* the stream. Not fixable client-side — the transcode is the
* problem. Tell the user instead of holding on a black frame. */
log_line("video: active but 0 frames decoded (HW decoder "
"rejected the stream, or running under Vita3K)");
show_blocking_error("This video's bitrate is too high to play",
"The player started but produced no frames. On a real "
"Vita this means the clip's bitrate exceeds the H.264 "
"level it declares, so the hardware decoder rejects it. "
SERVER_FIX_STEPS
"\n(The Vita3K emulator also decodes no video at all.)");
}
/* 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 && frames > 0) {
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_video_frame(cur);
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();
}
/* ---- pre-download bitrate probe ------------------------------------------
* Before pulling the whole stream we fetch just the MP4 header (the small
* `moov` box) and decide whether the Vita's hardware H.264 decoder will accept
* it. Everything we need is in `moov`: `avcC` carries the declared H.264 level
* (which the decoder sizes its buffers from and refuses to overrun), and the
* total file size (from the range response) over the `mvhd` duration gives the
* average bitrate. If that bitrate exceeds the level's ceiling we show the
* "too high" message immediately instead of downloading the whole clip only to
* fail at playback. Immich's transcodes are faststart (moov at the front), so
* a small head request usually has it; if not, we try the tail. The estimate
* is approximate (average vs the decoder's peak/buffer limit), so this only
* blocks clear cases and otherwise defers to the real playback attempt. */
enum { PROBE_OK = 0, PROBE_TOO_HIGH = 1, PROBE_UNKNOWN = -1 };
#define PROBE_HEAD_BYTES (256 * 1024)
static uint32_t rd_be32(const unsigned char *p)
{
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | p[3];
}
static uint64_t rd_be64(const unsigned char *p)
{
return ((uint64_t)rd_be32(p) << 32) | rd_be32(p + 4);
}
/* locate a 4CC box tag in a buffer; returns a pointer to the tag (payload
* follows immediately after the 4 bytes), or NULL. a plain scan is enough for
* the tags we want (avcC/mvhd/moov) — collisions in real files are negligible. */
static const unsigned char *find_tag(const unsigned char *buf, size_t len,
const char *tag)
{
if (len >= 4)
for (size_t i = 0; i + 4 <= len; i++)
if (buf[i] == (unsigned char)tag[0] &&
!memcmp(buf + i, tag, 4))
return buf + i;
return NULL;
}
/* the H.264 level's bitrate ceiling in Mbps (High-profile MaxBR). the decoder
* keys its buffer sizing off this, so a stream above it is what gets rejected. */
static double level_ceiling_mbps(int lvl)
{
switch (lvl) {
case 30: return 12.5;
case 31: return 17.5;
case 32: return 25.0;
case 40: case 41: case 42: return 62.5;
case 50: return 168.75;
case 51: case 52: return 300.0;
default: return lvl >= 51 ? 300.0 : lvl >= 40 ? 62.5 : 17.5;
}
}
/* capture the total file size from a 206's "Content-Range: bytes a-b/TOTAL" */
static size_t probe_hdr_cb(char *b, size_t s, size_t n, void *ud)
{
size_t len = s * n;
long long *total = ud;
if (len > 14 && !strncasecmp(b, "Content-Range:", 14)) {
char *slash = memchr(b, '/', len);
if (slash)
*total = strtoll(slash + 1, NULL, 10);
}
return len;
}
/* range GET [start,end] of the asset's playback stream into `out`; sets
* *total to the full file size from Content-Range. returns the HTTP status,
* or -1 on transport error. */
static int http_get_range(int idx, long start, long end, membuf *out,
long long *total)
{
char url[700];
snprintf(url, sizeof(url), "%s/api/assets/%s/video/playback",
g_server, g_asset_ids[idx]);
CURL *curl = curl_easy_init();
if (!curl)
return -1;
char keyhdr[300];
auth_header(keyhdr, sizeof(keyhdr));
struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr);
char range[64];
snprintf(range, sizeof(range), "%ld-%ld", start, end);
out->data = NULL;
out->size = 0;
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_RANGE, range);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, probe_hdr_cb);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, total);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);
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);
net_lock();
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);
if (res != CURLE_OK) {
free(out->data);
out->data = NULL;
return -1;
}
return (int)code;
}
/* decide from the header whether the clip is decodable. on TOO_HIGH, fills
* *level (level_idc, e.g. 31) and *mbps (estimated average bitrate). */
static int probe_video_bitrate(int idx, int *level, double *mbps)
{
membuf head;
long long total = -1;
int code = http_get_range(idx, 0, PROBE_HEAD_BYTES - 1, &head, &total);
/* need a real partial response (206) with a known total to estimate */
if (code != 206 || total <= 0) {
log_line("video probe %s: no usable range (code=%d total=%lld)",
g_asset_ids[idx], code, total);
free(head.data);
return PROBE_UNKNOWN;
}
const unsigned char *sbuf = (const unsigned char *)head.data;
size_t slen = head.size;
membuf tail;
tail.data = NULL;
/* faststart files have moov up front; otherwise grab the tail */
if (!find_tag(sbuf, slen, "moov") && total > (long long)head.size) {
long ts = total > PROBE_HEAD_BYTES ? (long)(total - PROBE_HEAD_BYTES) : 0;
long long t2 = -1;
if (http_get_range(idx, ts, total - 1, &tail, &t2) == 206) {
sbuf = (const unsigned char *)tail.data;
slen = tail.size;
}
}
const unsigned char *avcc = find_tag(sbuf, slen, "avcC");
const unsigned char *mvhd = find_tag(sbuf, slen, "mvhd");
int verdict = PROBE_UNKNOWN;
/* avcc payload[3] = level_idc; mvhd (v1) reads a 64-bit field at +24,
* so the payload must have 32 bytes available */
if (avcc && avcc + 4 + 4 <= sbuf + slen && mvhd && mvhd + 4 + 32 <= sbuf + slen) {
int lvl = avcc[4 + 3];
const unsigned char *m = mvhd + 4;
uint32_t timescale;
uint64_t duration;
if (m[0] == 1) { /* version 1: 64-bit times */
timescale = rd_be32(m + 20);
duration = rd_be64(m + 24);
} else { /* version 0: 32-bit times */
timescale = rd_be32(m + 12);
duration = rd_be32(m + 16);
}
double secs = timescale ? (double)duration / timescale : 0;
if (secs > 0.5 && lvl > 0) {
double est = (double)total * 8.0 / secs / 1e6;
double ceil_mbps = level_ceiling_mbps(lvl);
verdict = est > ceil_mbps ? PROBE_TOO_HIGH : PROBE_OK;
*level = lvl;
*mbps = est;
log_line("video probe %s: level %d.%d, ~%.1f Mbps "
"(%lld B / %.1fs), ceiling %.1f -> %s",
g_asset_ids[idx], lvl / 10, lvl % 10, est,
(long long)total, secs, ceil_mbps,
verdict == PROBE_TOO_HIGH ? "reject" : "ok");
}
}
if (verdict == PROBE_UNKNOWN)
log_line("video probe %s: header incomplete (avcC=%p mvhd=%p) - "
"deferring to playback", g_asset_ids[idx],
(const void *)avcc, (const void *)mvhd);
free(head.data);
free(tail.data);
return verdict;
}
/* download + play asset idx, cleaning up the temp file afterwards */
static void view_video(int idx)
{
/* the video's poster behind the download progress; the detail view
* pre-sets its sharper full-res poster, the grid falls back to the
* asset thumb. the modal download keeps the texture alive. */
if (!g_dl_bg)
g_dl_bg = g_thumb[idx];
g_video_rot = ori_to_deg(g_asset_rot[idx]);
/* check the header before committing to the full download, so an
* unplayable (too-high-bitrate) clip is caught up front */
vita2d_start_drawing();
vita2d_clear_screen();
if (g_dl_bg) {
draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H);
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H, RGBA8(0, 0, 0, 130));
}
draw_centered(SCREEN_H / 2, RGBA8(255, 255, 255, 255), "Checking video…");
vita2d_end_drawing();
vita2d_swap_buffers();
int level = 0;
double mbps = 0;
if (probe_video_bitrate(idx, &level, &mbps) == PROBE_TOO_HIGH) {
char det[512];
snprintf(det, sizeof(det),
"This clip is about %.0f Mbps but declares H.264 Level "
"%d.%d, whose bitrate ceiling the Vita's hardware decoder "
"enforces. " SERVER_FIX_STEPS,
mbps, level / 10, level % 10);
show_blocking_error("This video's bitrate is too high to play", det);
g_dl_bg = NULL;
return;
}
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);
}
g_dl_bg = NULL;
sceIoRemove(VIDEO_TMP_PATH);
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Generate a Web-Mercator tile pyramid (LODs) from a world map image and
pack it into one file for the Vita map view.
The source image covers a cropped-Mercator region: longitude spans its full
width, latitude band [MAP_IMG_MY0, MAP_IMG_MY1]. The default source carries a
baked-in 10° graticule, against which those bounds were fit (see map.c).
Each LOD level is the source scaled to a width, padded to whole 256px tiles,
and cut up; tiles are JPEG (decoded into pooled textures on device, the safe
path). Everything goes into assets/maptiles.pak:
magic 'MTIL' (4) u32 version u32 tile_size u32 nlevels
per level: u32 imgW imgH cols rows
u32 ntiles
per tile: u16 level u16 col u16 row u32 offset u32 size
<concatenated JPEG bytes>
offsets are from the start of the JPEG blob.
"""
import sys, struct, io
from PIL import Image
SRC = sys.argv[1] if len(sys.argv) > 1 else '/home/sads/Desktop/world map cities.jpeg'
OUT = sys.argv[2] if len(sys.argv) > 2 else '/home/sads/Code/vitaImmich/assets/maptiles.pak'
TILE = 256
QUALITY = 94
# level widths (px). The last entry (10496 ~= 1.5x the 7000px source) is a
# LANCZOS supersample: it adds no true detail beyond native, but lets a zoomed-in
# view sample crisp pre-upscaled tiles instead of the GPU bilinear-magnifying the
# native level, so city labels read sharper. heights follow aspect.
WIDTHS = [512, 1024, 2048, 4096, 7000, 10496]
src = Image.open(SRC).convert('RGB')
SW, SH = src.size
print('source', SW, SH, 'aspect', round(SW/SH, 4))
levels = [] # (imgW,imgH,cols,rows,Image-padded)
for w in WIDTHS:
if w > 2 * SW: # allow a modest supersample, but cap runaway upscales
w = 2 * SW
h = round(w * SH / SW)
img = src.resize((w, h), Image.LANCZOS)
cols = (w + TILE - 1) // TILE
rows = (h + TILE - 1) // TILE
# pad to whole tiles with the ocean colour so edge tiles aren't black
bg = src.getpixel((4, SH // 2)) # a sea pixel (top-left corner area is sea)
padded = Image.new('RGB', (cols * TILE, rows * TILE), bg)
padded.paste(img, (0, 0))
levels.append((w, h, cols, rows, padded))
print('level', len(levels)-1, 'img', w, h, 'tiles', cols, 'x', rows, '=', cols*rows)
# de-dup identical level widths (when native < a planned width)
seen = set(); uniq = []
for L in levels:
if L[0] in seen:
continue
seen.add(L[0]); uniq.append(L)
levels = uniq
blob = io.BytesIO()
index = [] # (level,col,row,offset,size)
for li, (w, h, cols, rows, padded) in enumerate(levels):
for r in range(rows):
for c in range(cols):
tile = padded.crop((c*TILE, r*TILE, c*TILE+TILE, r*TILE+TILE))
b = io.BytesIO(); tile.save(b, 'JPEG', quality=QUALITY)
data = b.getvalue()
off = blob.tell(); blob.write(data)
index.append((li, c, r, off, len(data)))
with open(OUT, 'wb') as f:
f.write(b'MTIL')
f.write(struct.pack('<III', 1, TILE, len(levels)))
for (w, h, cols, rows, _) in levels:
f.write(struct.pack('<IIII', w, h, cols, rows))
f.write(struct.pack('<I', len(index)))
for (li, c, r, off, sz) in index:
f.write(struct.pack('<HHHII', li, c, r, off, sz))
f.write(blob.getvalue())
import os
print('levels', len(levels), 'tiles', len(index),
'pak', round(os.path.getsize(OUT)/1e6, 2), 'MB')