# Decisions log

Append-only. Most recent first. Keep entries short — one paragraph max. Reference files with paths.

## 2026-04-28 — Image cache with capped concurrency; on-main-thread texture upload

`gfx/images.jai` is a small async image cache. UI calls `image_request(item_id, size)` from the draw loop; it always returns an `*Image` immediately whose `.loaded` flips true once the bytes arrive. Concurrency is capped at `MAX_CONCURRENT_IMAGE_FETCHES = 4` via a pending queue (`image_pending`) drained by `image_pump()` once per frame — without this, a 500-album library would spawn 500 worker threads at first scroll. Decode + texture upload runs in the http callback (which is already main-thread per `jellyfin/async.jai`), satisfying OpenGL's no-cross-thread rule. Two cache buckets per item, keyed `t:<id>` (128 px thumb) and `l:<id>` (512 px). Sizing is requested from Jellyfin via `?fillHeight=N&fillWidth=N&quality=80` so we get a tight payload instead of full-resolution originals.

## 2026-04-28 — Visualizer reads a real FFT from the active stream's PCM

`audio/fft.jai` ships a small in-place radix-2 Cooley-Tukey FFT (~80 lines, no deps). `audio/analysis.jai` pulls FFT_SIZE=1024 frames from `app.current_stream.sound_data.samples` around `play_cursor` each frame, mixes to mono, applies a Hann window, FFTs, then bins magnitudes into SPECTRUM_BINS (=64) log-spaced bands between 30 Hz and the stream's Nyquist. Asymmetric smoothing (fast attack 0.45, slow decay 0.10) gives the classic VU-meter feel. Spectrum decays toward zero when the stream is paused or when there's no LINEAR_SAMPLE_ARRAY data. Since /universal-fetched tracks always decode through dr_libs to LINEAR_SAMPLE_ARRAY, the OGG-pass-through fallback rarely triggers.

## 2026-04-28 — Track download uses `/Audio/{id}/universal`, not `/stream`

`/Audio/{id}/stream` returns the raw original file. For libraries with M4A / AAC / Opus / ALAC / WMA tracks, `dr_mp3`/`dr_flac` can't decode the bytes and Sound_Player can't either — the user's "B-Roll" track came back as `format=UNKNOWN` then crashed in the diagnostic. `/Audio/{id}/universal?container=mp3,flac,ogg,wav&audioCodec=mp3&maxStreamingBitrate=320000` lets the server send unmodified bytes when the source is in our container list, and transcodes to MP3 otherwise. Required params are `userId` + `deviceId`; `api_key` is also added as a query param because some streaming proxies strip the X-Emby-Authorization header. libcurl `FOLLOWLOCATION` is enabled — `/universal` 302-redirects to the transcoded stream URL.

## 2026-04-28 — Keep Sound_Player; add MP3/FLAC decoders, don't swap to miniaudio

User feedback when I proposed replacing Sound_Player with miniaudio: "I wouldn't change sound_player out, I would just add decoding from a few formats." So MP3/FLAC support is added by decoding bytes to s16 PCM via vendored `dr_mp3.h`/`dr_flac.h` and feeding the result into Sound_Player as `Sound_Data` with `type = .LINEAR_SAMPLE_ARRAY`. OGG and WAV continue to go through `Sound.load_audio_data` directly. Bigger libraries that prefer streaming (long tracks) might want a real decoder backend, but for now full-decode-to-PCM is simple and works.

## 2026-04-28 — HTTP runs on a per-request worker thread

All Jellyfin requests block the calling thread (libcurl `easy_perform`), and on the main thread that froze the UI for hundreds of ms (listings) or seconds (track downloads). Each request now spawns a `Thread` via `Thread.thread_init` + `thread_start`, the worker writes the result, the main loop calls `http_pump()` each frame which polls `thread_is_done` and fires the `on_done` callback. Per-request threads (no shared queue) keep the surface minimal — fine for ~5 concurrent requests; if we ever flood, switch to a thread pool.

## 2026-04-28 — Auto-advance via `release_asset` callback + `track_finished` flag

Sound_Player calls the `release_asset` callback when a stream finishes (from inside `Sound.update()`, which runs on the main thread, so it's safe to write app state). The callback sets `app.track_finished = true`, the main loop reads/clears it after `Sound.update()` and calls `queue_next()`. Streams are tagged with a monotonic `entity_id` (`app.track_entity_id`) so the callback can ignore stale finishes from streams we already replaced.

## 2026-04-28 — Pause is a volume mute, not a real pause

Sound_Player doesn't expose a clean per-stream pause. `audio_toggle_pause` flips `user_volume_scale` between 0 and 1. The cursor keeps moving while "paused" — so this is only correct for short pauses; the scrub bar is misleading and auto-advance still happens at the real end. Logged in `ai/todo.md` for a proper fix later.

## 2026-04-28 — Persist auth token, not password

`~/.config/player/config.json` stores `{server_url, username, auth_token, user_id}`. Password is never persisted. On startup, if `auth_token` and `user_id` are non-empty we mark `logged_in = true` and skip to library. If the server has invalidated the token, the first API call fails — user can hit "logout" to clear and re-enter password. Could auto-fall-back to login on 401, not yet wired.

## 2026-04-28 — GetRect widget wrappers thread `loc := #caller_location`

When wrapping stateful GetRect widgets (`text_input`, `button`, etc.) in a helper proc, the helper must accept `loc := #caller_location` and pass `loc=loc` to each widget. If multiple stateful widgets are drawn from the same wrapper, use `identifier=N` (unique constant per widget) to disambiguate them. See `src/ui/views/login_view.jai:text_field`. Without this, every call site of the wrapper looks identical to GetRect and you hit the runtime "widget drawn more than once" error.

## 2026-04-28 — `lib/libcurl.so` symlink for systems without libcurl-dev

Ubuntu 24.04 ships `libcurl.so.4` but not the unversioned `libcurl.so` symlink (which would come from `libcurl4-openssl-dev`). The bundled Curl module's `linux/lib/libcurl.so` requires `libssl.so.1.1` which Ubuntu 24.04 doesn't have. Workaround: `lib/libcurl.so` symlink in this repo pointing at the system `libcurl.so.4`, plus `additional_linker_arguments = ["-Llib"]` in `build.jai`. `Curl` is imported as `Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true)`.

## 2026-04-28 — Vendor Jaison + stb_image, use bundled Curl

Jellyfin returns JSON, so Jaison is the obvious pick. For artist images we'll need PNG/JPG decode → `stb_image` (single header, ubiquitous). Both vendored under `modules/`. For HTTP we use the Jai-bundled `Curl` module rather than vendoring a single-header HTTP lib — libcurl is already the most-used C HTTP client and the bindings exist.

## 2026-04-28 — Folder-based code split with `index.jai` per folder

Each subfolder of `src/` has an `index.jai` that `#load`s its files. `src/main.jai` only loads the index files. Keeps `main.jai` tiny per the user's brief.

## 2026-04-28 — One global `app: App` struct

Instead of threading state through procs, everything hangs off a single global. Smaller files, less ceremony, fits a vibe-coded project. If it gets messy we'll regret it and split.

## 2026-04-28 — `Simp.set_render_target(window, .LEFT_HANDED)` + `GetRect_LeftHanded`

Per user brief. Left-handed UI coords (y goes down) match how everyone thinks about UI.

## 2026-04-28 — Artist backdrop images in now-playing view

`Image_Size.BACKDROP` added to `gfx/images.jai` — 1920x1080 16:9 images fetched from `/Items/{id}/Images/Backdrop/0`. Cache key prefix is `b:` (disk: `b_<id>.bin`). The backdrop renders full-screen behind the now-playing UI with a 60% black tint overlay so text remains readable. `Track.artist_id` field added (populated from `AlbumArtistId` in Jellyfin responses) so we can request the correct backdrop for the current track's artist. Backdrops are fetched lazily when a track plays; if none exists, the view falls back to a solid dark background.
