Compare commits
10
Commits
ebf02126ba
...
07890191cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07890191cf | ||
|
|
f4cf235c40 | ||
|
|
b6eb700248 | ||
|
|
9f647c034a | ||
|
|
5a0eb06d3e | ||
|
|
02f428a76d | ||
|
|
66b9d77ac4 | ||
|
|
351804819b | ||
|
|
341d913346 | ||
|
|
f145b40471 |
-124
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
@@ -32,6 +32,10 @@ target_link_libraries(${PROJECT_NAME}
|
||||
jpeg
|
||||
z
|
||||
m
|
||||
# OpenSSL 1.0.2's libcrypto.a has a threads_pthread.o that references
|
||||
# pthread_getspecific/pthread_self/pthread_equal; the Vita newlib stubs that
|
||||
# live in libpthread.a.
|
||||
pthread
|
||||
SceDisplay_stub
|
||||
SceGxm_stub
|
||||
SceSysmodule_stub
|
||||
@@ -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_device.png cloud_device.png
|
||||
FILE ${CMAKE_SOURCE_DIR}/assets/cloud_both.png cloud_both.png
|
||||
FILE ${CMAKE_SOURCE_DIR}/assets/maptiles.pak maptiles.pak
|
||||
)
|
||||
|
||||
+36
-18
@@ -1,28 +1,46 @@
|
||||
FROM vitasdk/vitasdk:latest
|
||||
|
||||
RUN apk add --no-cache \
|
||||
cmake \
|
||||
make \
|
||||
git \
|
||||
ca-certificates
|
||||
# The published image has changed base distro over time (Alpine -> Ubuntu), so
|
||||
# probe for the package manager instead of assuming `apk`. Recent images
|
||||
# already ship cmake/make/git, in which case this layer is a no-op.
|
||||
RUN if command -v apk >/dev/null 2>&1; then \
|
||||
apk add --no-cache cmake make git ca-certificates; \
|
||||
elif command -v apt-get >/dev/null 2>&1; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake make git ca-certificates && rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm
|
||||
# -f = force install (also works for first-time installs)
|
||||
RUN vdpm -f \
|
||||
zlib \
|
||||
libpng \
|
||||
libjpeg-turbo \
|
||||
freetype \
|
||||
openssl \
|
||||
curl \
|
||||
zstd \
|
||||
vita2d
|
||||
# vdpm is pre-installed at /usr/local/vitasdk/bin/vdpm; -f = force install.
|
||||
#
|
||||
# NOTE 1: `openssl` (1.0.2) is deliberately NOT listed. The SDK's libcurl.a
|
||||
# (curl 8.17) is prebuilt against OpenSSL 1.0.2, but the image ships
|
||||
# openssl-1.1.1 as the installed default (a dependency of cpython3) and the two
|
||||
# vdpm packages conflict, so `vdpm openssl` aborts and leaves libcurl
|
||||
# referencing 1.0.2-only symbols (EVP_MD_CTX_create, SSLeay,
|
||||
# SSLv23_client_method, ...) that 1.1.1 does not provide -> link failure.
|
||||
# The RUN below overlays the matching 1.0.2 libs/headers from the tarball.
|
||||
#
|
||||
# NOTE 2: vita2d is NOT a vdpm package (it ships with the SDK core).
|
||||
RUN yes | vdpm -f zlib libpng libjpeg-turbo freetype curl zstd
|
||||
|
||||
# Overlay the OpenSSL 1.0.2 build libcurl was compiled against (see above).
|
||||
# This is the `openssl` vdpm package from the release snapshot the image's own
|
||||
# package DB points at; the SHA256 is the one recorded in that DB.
|
||||
ARG OPENSSL_URL=https://github.com/vitasdk/vitasdk-autobuild/releases/download/packages-2026.08-snapshot-20260925.50.1/openssl-1.0.2.r12304.gcd52124-1-vita.pkg.tar.xz
|
||||
ADD ${OPENSSL_URL} /tmp/openssl102.tar.xz
|
||||
RUN echo "1f97f3658fa6099354547202e2a9df6fa2919c60bcce314c83e99ef25de8998e /tmp/openssl102.tar.xz" | sha256sum -c - && \
|
||||
mkdir -p /tmp/o102 && tar xJf /tmp/openssl102.tar.xz -C /tmp/o102 && \
|
||||
cp -a /tmp/o102/arm-vita-eabi/lib/libcrypto.a \
|
||||
/tmp/o102/arm-vita-eabi/lib/libssl.a \
|
||||
/usr/local/vitasdk/arm-vita-eabi/lib/ && \
|
||||
cp -a /tmp/o102/arm-vita-eabi/include/openssl/. \
|
||||
/usr/local/vitasdk/arm-vita-eabi/include/openssl/ && \
|
||||
rm -rf /tmp/o102 /tmp/openssl102.tar.xz
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && \
|
||||
cmake --build /build --parallel "$(nproc)"
|
||||
RUN cmake -B /build -DCMAKE_BUILD_TYPE=Release . && cmake --build /build --parallel 4
|
||||
|
||||
# Mount a host directory to /output to retrieve the VPK
|
||||
CMD ["sh", "-c", "cp /build/vitaImmich.vpk /output/vitaImmich.vpk && echo 'Done: /output/vitaImmich.vpk'"]
|
||||
@@ -1,151 +1,157 @@
|
||||
# vitaImmich
|
||||
<div align="center">
|
||||
|
||||
Minimal proof-of-concept [Immich](https://immich.app/) client for the PS Vita.
|
||||
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.
|
||||
# 📷 vitaImmich
|
||||
|
||||
## Requirements
|
||||
### Your self-hosted photo library, in your hands.
|
||||
|
||||
- A PS Vita with HENkaku/h-encore and VitaShell
|
||||
- An Immich server reachable from the Vita's Wi-Fi (recent Immich version —
|
||||
it uses `POST /api/search/random` and `GET /api/assets/{id}/thumbnail`)
|
||||
- An Immich API key (web UI → Account Settings → API Keys)
|
||||
A native **[Immich](https://immich.app/)** client for the **PlayStation Vita** —
|
||||
browse, search and map your whole photo & video library over Wi‑Fi, play videos
|
||||
on the Vita's hardware decoder, and back up the camera roll, all on a 12‑year‑old
|
||||
handheld.
|
||||
|
||||
## Building
|
||||
</div>
|
||||
|
||||
Needs [VitaSDK](https://vitasdk.org/) with the `libvita2d`, `curl`, `openssl`,
|
||||
`zstd`, `zlib`, `libpng`, `libjpeg-turbo` and `freetype` packages installed
|
||||
(via `vdpm`).
|
||||
<table>
|
||||
<tr>
|
||||
<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 & search</b></td>
|
||||
<td align="center"><b>Places map</b></td>
|
||||
<td align="center"><b>Server & 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.
|
||||
2. Launch it once — it creates `ux0:data/vitaimmich/config.txt`.
|
||||
3. Edit that file (VitaShell → SELECT for FTP makes this easy):
|
||||
> **Status:** proof‑of‑concept. App title id **`VIMM00001`**. See [limitations](#-limitations).
|
||||
|
||||
```
|
||||
## 📦 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
|
||||
apikey=your-immich-api-key
|
||||
```
|
||||
|
||||
If your server sits behind a public domain (reverse proxy) and your
|
||||
router does not support NAT loopback, add the server's LAN IP so the
|
||||
domain is pinned to it while on your home network:
|
||||
Behind a reverse proxy without NAT loopback? Pin the domain to your LAN IP
|
||||
so the Host header / TLS SNI stay on the domain but the connection goes
|
||||
straight to the server:
|
||||
|
||||
```
|
||||
```ini
|
||||
server=https://immich.example.com
|
||||
apikey=your-immich-api-key
|
||||
serverip=192.168.1.100
|
||||
```
|
||||
|
||||
This keeps the Host header and TLS SNI on the domain (so the reverse
|
||||
proxy still routes correctly) but connects to the LAN IP directly.
|
||||
4. Relaunch.
|
||||
|
||||
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 |
|
||||
| ------------- | ------------------------------- |
|
||||
| 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 |
|
||||
**Places map**
|
||||
|
||||
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 |
|
||||
| ------------- | --------------------------------- |
|
||||
| Left / Right | Previous / next item |
|
||||
| X | Play video / retry a failed photo |
|
||||
| O | Back to grid |
|
||||
| SELECT | Open the sync overview |
|
||||
| START | Exit |
|
||||
**Full‑screen viewer**
|
||||
|
||||
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 |
|
||||
| ------------- | ----------------------------------- |
|
||||
| X | Queue all local-only files to upload|
|
||||
| O / SELECT | Back |
|
||||
| START | Exit |
|
||||
**Video playback** — X pause/resume · ←/→ seek ±10 s · O stop.
|
||||
|
||||
Video playback:
|
||||
## 🔧 Building
|
||||
|
||||
| Button | Action |
|
||||
| ------------- | ----------------- |
|
||||
| X | Pause / resume |
|
||||
| Left / Right | Seek ±10 seconds |
|
||||
| O | Stop and go back |
|
||||
Needs [VitaSDK](https://vitasdk.org/) with `libvita2d`, `curl`, `openssl`,
|
||||
`zstd`, `zlib`, `libpng`, `libjpeg-turbo` and `freetype` (install via `vdpm`).
|
||||
|
||||
More of the library is fetched automatically (100 photos at a time, newest
|
||||
first) as you scroll toward the end. Errors are shown on screen and logged
|
||||
to `ux0:data/vitaimmich/log.txt`.
|
||||
```sh
|
||||
export VITASDK=$HOME/vitasdk
|
||||
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
|
||||
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:
|
||||
## ☁️ Camera‑roll backup
|
||||
|
||||
- blue-grey dot — **cloud only** (a server asset not present on the Vita)
|
||||
- orange up-arrow — **local only** (on the Vita, not yet on the server)
|
||||
- blinking orange — **queued / uploading**
|
||||
- green check — **backed up** (exists on both; shown once, as the server asset)
|
||||
- red dot — **failed** (see the sync overview for the reason)
|
||||
On startup the app mounts the Photos app's storage (`photo0:` /
|
||||
`ux0:picture`) plus `ux0:video/CAMERA`, scans for `.jpg/.jpeg/.png/.mp4`, and
|
||||
merges those files into the same date‑sorted timeline as your server library. A
|
||||
badge in each cell's corner shows status:
|
||||
|
||||
A background thread hashes each local file (SHA1) and asks the server which
|
||||
ones already exist (`POST /api/assets/bulk-upload-check`). Hashing and the
|
||||
duplicate check start automatically. Uploads do **not** start on their own:
|
||||
open the sync overview with **SELECT** and press **X** to queue every
|
||||
local-only file. Each upload is a `POST /api/assets` multipart request with
|
||||
an `x-immich-checksum` header for fast server-side dedup; progress is shown
|
||||
in the overview.
|
||||
- 🔵 blue‑grey dot — **cloud only**
|
||||
- 🔶 orange up‑arrow — **local only**
|
||||
- 🟠 blinking — **queued / uploading**
|
||||
- 🟢 green check — **backed up** (on both; shown once, as the server asset)
|
||||
- 🔴 red dot — **failed**
|
||||
|
||||
## 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
|
||||
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
|
||||
keep-alive connection); cells show a grey placeholder until loaded.
|
||||
- Videos are downloaded in full to `ux0:data/vitaimmich/video.mp4` before
|
||||
playback (deleted afterwards), so they need free space on the memory card
|
||||
and a moment to start. Playback uses the Vita's hardware decoder via
|
||||
SceAvPlayer, so only MP4 (H.264/AAC) plays — Immich's transcoded
|
||||
`video/playback` stream is used, which is H.264 with default server
|
||||
settings. The decoder rotates four output frame buffers (~13 MB CDRAM at
|
||||
1080p) so async GPU rendering can't be torn by a recycled frame.
|
||||
- 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
|
||||
~2 levels; use `syncdir=` in the config to scan other folders.
|
||||
- Local video grid cells show a poster thumbnail (with the VIDEO badge):
|
||||
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.
|
||||
## ⚠️ Limitations
|
||||
|
||||
- **TLS verification is disabled** (no CA bundle ships) — prefer plain HTTP on a
|
||||
trusted LAN, or treat HTTPS as unverified.
|
||||
- Videos download in full to the memory card before playing (deleted after).
|
||||
Playback is the Vita's hardware decoder via SceAvPlayer, so only **MP4
|
||||
(H.264/AAC)** plays — Immich's transcoded `video/playback` stream is used.
|
||||
- Backup is **one‑way** (Vita → server); `deviceId` is hard‑coded to "PS Vita"
|
||||
and uploads aren't added to an album.
|
||||
- Thumbnails stream in one at a time on a background thread (grey placeholder
|
||||
until loaded); hashing the camera roll fills statuses in gradually.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Made by <b>SadsArches</b> with love. ❤️
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 376 KiB |
@@ -2,7 +2,24 @@
|
||||
|
||||
Task list for the current round of work. Checked items are done.
|
||||
|
||||
## -1. UI polish round 2 (current)
|
||||
## -2. Albums (current)
|
||||
- [x] Third top-bar button (photo-stack glyph) left of the map/cloud
|
||||
buttons; d-pad focus cycles field -> albums -> map -> cloud, tap works.
|
||||
- [x] Albums page: GET /api/albums, 4-column card grid (cover cropped to
|
||||
4:3, name, item count, "Shared"), d-pad/tap, X open, O back,
|
||||
triangle refresh. Covers load through the shared thumbnail worker
|
||||
(cover id -> asset_for_id) and only rows near the viewport keep a
|
||||
texture.
|
||||
- [x] Opening an album fetches every member with /api/search/metadata
|
||||
({"albumIds":[id]} paged by nextPage, nextCursor followed if sent)
|
||||
on a worker thread with a progress throbber, then shows them on the
|
||||
grid in month sections; the bar shows the album name and O (or the
|
||||
red chip) returns to the album list.
|
||||
- [x] Test-only scripted input (-DVIMM_TEST_INPUT, reads
|
||||
ux0:/data/vitaimmich/test_input.txt) for Vita3K runs where synthetic
|
||||
key events don't reach the emulator.
|
||||
|
||||
## -1. UI polish round 2
|
||||
- [x] Reload images taken by the camera while the app was asleep: detect
|
||||
resume via an RTC wall-clock jump (>3 s) and append-only re-scan the
|
||||
camera folders (headroom reserved at startup so no realloc races the
|
||||
|
||||
+320
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+143
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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(¶m);
|
||||
}
|
||||
sceNetCtlInit();
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t size;
|
||||
} membuf;
|
||||
|
||||
static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
|
||||
{
|
||||
membuf *mb = userdata;
|
||||
size_t add = size * nmemb;
|
||||
char *p = realloc(mb->data, mb->size + add + 1);
|
||||
if (!p)
|
||||
return 0;
|
||||
mb->data = p;
|
||||
memcpy(mb->data + mb->size, ptr, add);
|
||||
mb->size += add;
|
||||
mb->data[mb->size] = '\0';
|
||||
return add;
|
||||
}
|
||||
|
||||
/* verbose-log the first request via curl's debug stream so connection
|
||||
* problems land in log.txt with curl's own explanation */
|
||||
static int g_verbose_request = 1;
|
||||
|
||||
static int curl_debug_cb(CURL *h, curl_infotype type, char *data,
|
||||
size_t size, void *ud)
|
||||
{
|
||||
if (type == CURLINFO_TEXT && size > 0) {
|
||||
char line[300];
|
||||
if (data[size - 1] == '\n')
|
||||
size--;
|
||||
snprintf(line, sizeof(line), "%.*s", (int)size, data);
|
||||
log_line("curl: %s", line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user