# Decisions log

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

## 2026-05-01 — Single audio pipeline: OGG-only via stdlib stb_vorbis

Jellyfin transcodes everything to OGG Vorbis (server is beefy, transcode cost is irrelevant). On our side, `decode_ogg` (`src/audio/decoders.jai`, ~30 lines) calls `stb_vorbis_decode_memory` to produce one s16 PCM buffer that we hand to Sound_Player as `LINEAR_SAMPLE_ARRAY`. The visualizer reads from the *same* `sd.samples` around `play_cursor` (frames, scaled by `nchannels` to index the interleaved buffer) — no parallel stb_vorbis decoder, no `analysis_vorbis`/`analysis_ogg` state, no cursor desync between bars and audio. Killed: the `Audio_Format` enum, `detect_audio_format`, the dr_mp3/dr_flac branches, the `Sound.load_audio_data` OGG_COMPRESSED path, the `current_format` field on App, the `[%]` format suffix in the now-playing label.

## 2026-05-01 — Drop vendored audio_decoders + stb_image; use Jai stdlib

The dr_mp3/dr_flac C wrapper module (`modules/audio_decoders/`) and its build hook (`ensure_audio_decoders_built` in `build.jai` → `source/build.sh` → `decoders.a`) existed only to support MP3/FLAC. With OGG-only playback that's all dead. Deleted. Vendored `modules/stb_image/` was older than the stdlib copy (missing Android/NN_SWITCH2 OS dispatch) — also deleted; `Stb_Image :: #import "stb_image"` now resolves to the stdlib. Only thing still vendored is `Jaison` (no stdlib JSON parser). Result: no C build step, no static archive to manage, smaller binary (4.92 MB → 4.85 MB), `core/imports.jai` lost a line.

## 2026-05-01 — DeviceId is per-install, generated once and persisted

Jellyfin permits exactly one active access token per `DeviceId`. A hardcoded constant (`"player-dev-device"`) meant any second instance, second machine, or re-login flow silently revoked the prior saved token — root cause of the 401-on-launch bug we hit. A 32-char hex `device_id` is now generated on first run via `random_get()` (Random seeded from `current_time_monotonic()` in `app_init`), stored alongside the token in `config.json`, and reused forever. `ensure_device_id()` runs every launch so existing configs migrate in. `jellyfin_force_logout` (the on-401 path) deliberately keeps the device_id — burning it would orphan the device entry server-side.

## 2026-05-01 — `Authorization: MediaBrowser ...`, not `X-Emby-Authorization`

The legacy `X-Emby-Authorization` header is being removed in Jellyfin 12.0; admins on 10.11+ can already disable it via `EnableLegacyAuthorization=false`. Switched both `client.jai` (sync path) and `async.jai` (worker path) to the modern `Authorization` header. Same value format. Client name is `"Jellyfin Celica Music Player"` and Device is `"Celica"` so the device shows up sensibly in Dashboard → Devices.

## 2026-05-01 — Validate saved token at startup; auto-drop to login on 401

After `config_load()` succeeds, `app_init` fires `jellyfin_validate_session_async()` (a `GET /System/Info` probe) instead of jumping straight to library. On 200 we proceed; on 401 `jellyfin_force_logout()` clears the token, persists, and switches view to `.LOGIN` (preserving server_url/username/device_id for one-click re-auth). The library callbacks also check `status_code == 401` so a mid-session revoke from the dashboard doesn't leave the user on an empty library. On non-401 failures (network blip, server down) we keep the saved token and just route to login — never punish a transient error with a token wipe. Background: Jellyfin AccessTokens have no time-based expiry; they only die from same-DeviceId re-auth, password change, or admin revoke.

## 2026-05-01 — Quick Connect as a second login path

`src/jellyfin/quick_connect.jai` implements the three-step flow: `POST /QuickConnect/Initiate` → display 6-char code → poll `GET /QuickConnect/Connect?secret=…` every 3s → on `Authenticated:true`, `POST /Users/AuthenticateWithQuickConnect` with `{Secret}` to claim an `AccessToken`. Polling is driven by per-frame `jellyfin_quick_connect_pump()` in the main loop (no sleeping thread). State (`qc_state`, `qc_code`, `qc_secret`, `qc_poll_at`) lives on `Jellyfin_Client`; `draw_login_view` swaps between the password form and a "enter this code" panel based on `qc_state`. Lets the user log in without typing a password into the player. Field name `Code` shadows a Jai primitive, so the parse struct uses `UserCode: string; @JsonName(Code)` for Jaison's rename note.

## 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.
