This commit is contained in:
Tuomas Katajisto 2026-04-29 06:57:46 +03:00
commit c8c69366b5
73 changed files with 30254 additions and 0 deletions

52
CLAUDE.md Normal file
View File

@ -0,0 +1,52 @@
# player — Jellyfin music player in Jai
This is a vibe-coded project. Pure AI-assisted work. Take agency, but check `ai/` for current direction before making big calls.
## What this is
A desktop music player that talks to a Jellyfin server. End goal: looks like a ridiculous 2000s audio player that shipped on a CD-ROM with an album — chunky skinned UI, neon gradients, glossy buttons, a spectrum visualizer behind the now-playing screen, music-reactive shaders, artist art front and center.
## Build
```
cd /home/katajisto/player
/home/katajisto/bin/jai/bin/jai-linux build.jai
./build/player
```
`build.jai` is a metaprogram. It points the import path at `./modules` (vendored: `Jaison`, `stb_image`) and falls back to the standard Jai modules at `/home/katajisto/bin/jai/modules`.
## Code layout
`src/main.jai` is intentionally tiny — it just `#load`s an `index.jai` from each subfolder. Each subfolder owns its surface area:
- `core/` — app state, window, frame loop, time
- `jellyfin/` — HTTP client (libcurl), auth, library browsing, image fetch, stream URLs
- `audio/` — Sound_Player wrapper, queue, FFT analysis for the visualizer
- `ui/` — theme, fonts, and `views/` (one file per screen)
- `gfx/` — custom shaders, texture loading
- `util/` — log, helpers
Each folder has an `index.jai` that `#load`s every file in that folder. To add a file, drop it in and add a `#load` line.
## Dependencies
- **Simp** (Jai stdlib) — rendering
- **GetRect_LeftHanded** (Jai stdlib) — UI widgets
- **Sound_Player** (Jai stdlib) — audio playback (ALSA on linux, CoreAudio on mac)
- **Curl** (Jai stdlib) — HTTP to Jellyfin
- **Jaison** (vendored) — JSON
- **stb_image** (vendored) — single-header PNG/JPG decode for artist images
Cross-platform target: linux + macOS.
## AI notes
See `ai/` for live design notes:
- `ai/aesthetic.md` — visual direction
- `ai/architecture.md` — code layout rationale
- `ai/decisions.md` — decision log
- `ai/todo.md` — running todo / scratch
Update `ai/decisions.md` when you make a non-obvious choice. Update `ai/todo.md` when you punt on something.

32
ai/aesthetic.md Normal file
View File

@ -0,0 +1,32 @@
# Aesthetic direction
Target: a ridiculous audio player from the early 2000s — the kind that shipped on a CD-ROM with an album. Think:
- The Linkin Park *Meteora* player
- Björk's *Biophilia* app (later, but same energy)
- Custom Winamp skins from skins.com circa 2002
- Limited-edition iPod-companion players
## Visual cues
- **Chunky** UI elements. Big buttons, big knobs, big sliders. No sleek minimalism.
- **Neon + metallic gradients**. Electric blue, hot pink, lime, chrome silver. Glossy highlights on buttons.
- **Drop shadows, glows, bevels**. Embrace the skeuomorphism.
- **Non-grid layouts**. The play controls don't have to live in a tidy bottom bar — they can be a curved cluster off to the side.
- **Album art is huge**. Artist photo as a backdrop with a darkened gradient over it.
- **Spectrum visualizer** behind the now-playing screen, reacting to the audio FFT.
- **Custom shader effects** — bloom, scanlines, chromatic aberration, plasma, whatever fits.
- **Cheesy fonts**. Bold italic, maybe a script font for the artist name.
## What to avoid
- Spotify/Apple Music minimalism
- Material Design / Fluent UI
- Flat design
- Anything that looks like it was designed in 2018+
## Practical notes for the codebase
- We get rect-based widgets from `GetRect_LeftHanded`, but we should freely draw on top of them with `Simp.immediate_*` and custom shaders for the album-skin aesthetic.
- `gfx/shaders.jai` is where custom GLSL lives.
- The visualizer is a fullscreen quad behind the UI sampling FFT bins from `audio/analysis.jai`.

52
ai/architecture.md Normal file
View File

@ -0,0 +1,52 @@
# Architecture
## File layout
```
player/
├── build.jai # metaprogram, sets import_path
├── CLAUDE.md # project context
├── ai/ # AI-collaboration notes
├── data/ # fonts, static assets
├── modules/ # vendored: Jaison, stb_image
└── src/
├── main.jai # tiny entry, #loads each index.jai
├── core/index.jai # app state, window, time
├── jellyfin/index.jai # http client + endpoints
├── audio/index.jai # Sound_Player wrapper, queue, FFT
├── ui/index.jai # theme, fonts, views
├── gfx/index.jai # custom shaders, textures
└── util/index.jai # log, helpers
```
## Convention: `index.jai` per folder
Each folder has an `index.jai` that `#load`s every other `.jai` file in the folder. `main.jai` only loads the `index.jai` files. This keeps `main.jai` ~10 lines and lets us add a new file by editing one `#load` line.
## Convention: one giant `App` struct
`core/app.jai` defines `App :: struct { ... }` and a global `app: App;`. Everything that needs to live across frames hangs off `app` — the window handle, fonts, the jellyfin client, the audio player, the UI state, the current view.
This is on purpose. We don't want plumbing arguments through five layers. The whole codebase has implicit access to `app`.
## Convention: views
A "view" is a screen (login, library, now-playing). Each lives in `src/ui/views/<name>_view.jai` and exposes one proc:
```jai
draw_<name>_view :: (dt: float)
```
`app.current_view` is an enum that picks which one runs each frame. Transitions are `app.current_view = .LIBRARY;`.
## Convention: HTTP
`jellyfin/client.jai` wraps libcurl with a small `http_get` / `http_post` helper that returns a string body + status code. The auth token gets stamped into headers automatically once `auth.login()` succeeds.
## Convention: audio
`audio/player.jai` owns a single `*Sound_Stream` for the currently playing track plus a queue of pending tracks. `Sound_Player` handles the actual decoding and output.
## Convention: gfx
`gfx/shaders.jai` keeps GLSL strings inline (compiled via Simp's GL backend). The visualizer is a fullscreen quad with a fragment shader that reads from FFT data uploaded as a 1D texture.

63
ai/decisions.md Normal file
View File

@ -0,0 +1,63 @@
# 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.

35
ai/todo.md Normal file
View File

@ -0,0 +1,35 @@
# Todo / scratch
## What works now
- Login → server hits `/Users/AuthenticateByName`, token+user_id persisted to `~/.config/player/config.json`
- Subsequent launches skip login, jump straight to library
- Library view: 3 columns (artists / albums / tracks), scrollable, click-to-drill
- **All HTTP is async**`http_submit` queues a worker thread; `http_pump()` runs once per frame and fires callbacks on the main thread. UI no longer freezes during fetches or downloads.
- **MP3 + FLAC playback** via vendored `dr_mp3.h` / `dr_flac.h` (compiled to `modules/audio_decoders/linux/decoders.a``build.jai` auto-rebuilds when the .c source is newer). Decoded to s16 PCM and handed to Sound_Player as `Sound_Data.LINEAR_SAMPLE_ARRAY`.
- OGG + WAV continue through `Sound.load_audio_data` (Sound_Player's native path).
- Click a track → downloads (async) → decodes → plays. Auto-advance via Sound_Player's `release_asset` callback firing `track_finished`.
- Pause/resume via `user_volume_scale` mute trick (still not a real pause)
- Now-playing screen with title/artist/album, transport buttons, scrub bar
- Logout button on library top-right clears `config.json`
## Known stubs / rough edges
- **Memory leak**: each played track leaks its decoded PCM buffer (~50 MB for a 5-min FLAC). The `release_asset` callback should call `decoder_free` for MP3/FLAC paths and `free` for OGG/WAV — currently it only flips `track_finished`. Track allocation type per-Sound_Data so the release knows which allocator to use.
- `audio_toggle_pause` is volume-mute, not real pause — cursor keeps advancing
- No real seek (scrub bar is read-only)
- Image cache never evicts — hold for thousands of items but unbounded over a long session (low priority)
- Failed image fetches stay marked `failed` forever; no retry. Often that's right (item has no art) but transient network errors should retry once
- `gfx/shaders.jai` is still `Simp.immediate_quad` (mirrored bars + bass pulse, but no GLSL fragment shader yet)
- `client.jai` still has the old sync `http_get`/`http_post` and `Http_Response` / `build_auth_header` — async.jai uses `build_auth_header` so the helpers stay, but the sync wrappers are dead code now
- Stream URL `/Audio/{id}/stream` returns the original file. If the original is something exotic (Opus, AAC, etc.) it'll fail. Could fall back to `/Audio/{id}/universal` with `audioCodec=mp3` or similar to force-transcode for unsupported formats.
## Next likely tasks (in rough order)
1. Free the decoded buffer when the stream ends (fix the leak)
2. Fetch primary image per album, decode with stb_image, upload as `Simp.Texture`, render in album rows + as backdrop on now-playing
3. Real FFT (dr_libs author's work? or kissfft) hooked to Sound_Player's mixed output buffer
4. Custom GLSL fragment shader for the visualizer (replaces the bar stack)
5. Album-level "play all" button in the album column
6. Real per-stream pause + seek
7. Search bar above artists column

73
build.jai Normal file
View File

@ -0,0 +1,73 @@
//
// Build metaprogram for the jellyfin music player.
//
// Run with: jai build.jai
//
// Adds ./modules to the import path so we can vendor Jaison and stb_image
// alongside the standard Jai modules.
//
#run build();
build :: () {
set_build_options_dc(.{do_output = false});
w := compiler_create_workspace("Player");
if !w {
compiler_report("Could not create workspace.");
return;
}
options := get_build_options(w);
copy_commonly_propagated_fields(get_build_options(), *options);
options.output_executable_name = "player";
options.output_path = "build/";
import_paths: [..] string;
array_add(*import_paths, tprint("%modules", #filepath));
for options.import_path array_add(*import_paths, it);
options.import_path = import_paths;
// Help the linker find libcurl.so. On a system where only libcurl.so.4 is
// installed (no -dev package), we fall back to a symlink in ./lib/.
extra_linker: [..] string;
array_add(*extra_linker, tprint("-L%lib", #filepath));
options.additional_linker_arguments = extra_linker;
// Build the dr_mp3/dr_flac wrapper if the static lib is missing or stale.
ensure_audio_decoders_built();
set_build_options(options, w);
make_directory_if_it_does_not_exist("build");
compiler_begin_intercept(w);
add_build_file(tprint("%src/main.jai", #filepath), w);
compiler_end_intercept(w);
}
ensure_audio_decoders_built :: () {
#if OS == .LINUX out := tprint("%modules/audio_decoders/linux/decoders.a", #filepath);
#if OS == .MACOS out := tprint("%modules/audio_decoders/macos/decoders.a", #filepath);
src := tprint("%modules/audio_decoders/source/decoders.c", #filepath);
script := tprint("%modules/audio_decoders/source/build.sh", #filepath);
if file_exists(out) {
out_modtime, _, out_ok := file_modtime_and_size(out);
src_modtime, _, src_ok := file_modtime_and_size(src);
if out_ok && src_ok && compare_apollo_times(out_modtime, src_modtime) >= 0 return;
log("audio_decoders: source newer than %, rebuilding\n", out);
}
result, output := run_command("/bin/sh", script, capture_and_return_output=true);
if result.exit_code != 0 {
compiler_report(tprint("audio_decoders build failed (exit=%):\n%", result.exit_code, output));
}
}
#import "Basic";
#import "Compiler";
#import "File";
#import "File_Utilities";
#import "Process";

BIN
build/player Executable file

Binary file not shown.

Binary file not shown.

1
lib/libcurl.so Symbolic link
View File

@ -0,0 +1 @@
/usr/lib/x86_64-linux-gnu/libcurl.so.4

View File

@ -0,0 +1,16 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

4
modules/Jaison/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.build/
*.dSYM
/modules
examples/example

3
modules/Jaison/.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "unicode_utils"]
path = unicode_utils
url = git@github.com:rluba/jai-unicode

21
modules/Jaison/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Raphael Luba
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,391 @@
#import, file "../module.jai";
main :: () {
{
// Use the temporary allocator so we don't have to worry about individually freeing
// all the allocations.
push_allocator(temp);
typed_parsing();
typed_printing();
generic_parsing();
generic_printing();
rename_by_note();
custom_rename_procedure();
decode_into_struct_with_using();
enum_errors();
}
// Since the program ends here, this doesn't matter, but just setting an example.
reset_temporary_storage();
}
// Data structures for typed parsing/printing
LevelData :: struct {
kind: LevelKind;
flags: LevelFlags;
secret: bool;
player: Entity;
player2: *Entity;
union {
score: float;
score_v2: float;
}
entities: [..] Entity;
floats: [] float;
LevelKind :: enum {
EASY :: 0;
HARD :: 1;
LEGENDARY :: 2;
}
LevelFlags :: enum_flags {
FLAG_A :: 0x1;
FLAG_B :: 0x2;
FLAG_C :: 0x4;
}
}
Entity :: struct {
name: string;
x, y: int;
dirty: bool; @JsonIgnore
}
LEVEL_DATA_1_JSON := #string DONE
{"kind": ".HARD", "flags": "FLAG_A | LevelFlags.FLAG_C", "secret": false,"score":5.5,"player": {"name": "Pat","x": 10,"y": 10},"player2": {"name": "Chris"},"entities": [{"name": "fdsa","x": 0,"y": 0},{"name": "fdsa","x": 0,"y": 0}], "floats": [0.00, 1.11111111111111111, 2.0202, 3e-5, 4.444444, -5.0]}
DONE;
LEVEL_DATA_2_JSON := #string DONE
{"kind": 2, "score_v2": 25.1}
DONE;
Fixed_Size_Data :: struct {
data: [3] int;
}
DATA_ARRAY_1 := #string DONE
{"data": [1, 2, 3]}
DONE;
DATA_ARRAY_2 := #string DONE
{"data": [1, 2]}
DONE;
typed_parsing :: () {
{
success, level := json_parse_string(LEVEL_DATA_1_JSON, LevelData, ignore_unknown=false);
// success, level := json_parse_file("level.json", LevelData, ignore_unknown=false);
assert(success);
log("Typed parsing result 1:\n%\nscore: %\n\n", level, level.score);
assert(level.floats.count == 6);
assert(level.floats[1] > 1); // Regression test, we had a bug here…
}
{
// Test parsing integers into enum slots & alternative union fields
success, level := json_parse_string(LEVEL_DATA_2_JSON, LevelData, ignore_unknown=false);
assert(success);
log("Typed parsing result 2:\n%\nscore: %\n\n", level, level.score);
assert(level.kind == .LEGENDARY);
assert(level.score == 25.1);
}
{
// Test parsing into fixed-sized arrays
success, result := json_parse_string(DATA_ARRAY_1, Fixed_Size_Data, ignore_unknown=false);
assert(success);
log("Typed parsing result 3:\n%\n\n", result);
assert(result.data[0] == 1);
assert(result.data[1] == 2);
assert(result.data[2] == 3);
}
{
// Test parsing into fixed-sized arrays (incorrect size)
success, result := json_parse_string(DATA_ARRAY_2, Fixed_Size_Data, ignore_unknown=false);
assert(!success);
}
}
typed_printing :: () {
level := LevelData.{kind=.LEGENDARY, flags=LevelData.LevelFlags.FLAG_B|.FLAG_C, secret=true, score=500};
level.player = .{name="Pat", x=4, y=4, dirty=true};
array_add(*level.entities, .{name="Chris", x=6, y=6});
json_string := json_write_string(level);
log("Typed printing result:\n%\n\n", json_string);
// success := json_write_file("level.json", level, indent_char="");
// assert(success);
}
generic_parsing :: () {
// In this scenario, some parts of the structure are known, but other parts are not.
json := #string DONE
{
"version": 3,
"entities": [
{
"name": "Player",
"x": 2,
"y": 2,
"player_index": 0
},
{
"name": "Snake",
"x": 4,
"y": 4,
"snake_color": 1
}
],
"stuff": [null, true, false]
}
DONE
success, root := json_parse_string(json);
// success, root := json_parse_file("level.json");
assert(success);
log("Generic parsing result:");
// Print things out, for demonstration purposes
traverse_node :: (node: JSON_Value, depth: int) {
INDENTATION :: 4;
print("% ", node.type);
if node.type == {
case .NULL;
print("\n");
case .BOOLEAN;
print("%\n", node.boolean);
case .NUMBER;
print("%\n", node.number);
case .STRING;
print("%\n", node.str);
case .OBJECT;
print("{\n");
for node.object {
for 1..(depth+1)*INDENTATION print(" ");
print("%: ", it_index);
traverse_node(it, depth+1);
}
for 1..depth*INDENTATION print(" ");
print("}\n");
case .ARRAY;
print("[\n");
for node.array {
for 1..(depth+1)*INDENTATION print(" ");
traverse_node(it, depth + 1);
}
for 1..depth*INDENTATION print(" ");
print("]\n");
}
}
traverse_node(root, 0);
print("\n");
// Convenience function for grabbing object members
get :: (json_val: JSON_Value, key: string, expected_type: JSON_Type) -> JSON_Value {
assert(json_val.type == .OBJECT);
table := json_val.object;
success, val := Hash_Table.table_find(table, key);
assert(success);
assert(val.type == expected_type);
return val;
}
// Check for version number that may or may not exist
version: float64 = -1;
assert(root.type == .OBJECT);
success2, val := Hash_Table.table_find(root.object, "version");
if success2 {
if val.type == .NUMBER {
version = val.number;
}
}
log("version: %\n", version);
// Traverse a structure we are confident about
for get(root, "entities", .ARRAY).array {
entity_name := get(it, "name", .STRING).str;
x := get(it, "x", .NUMBER).number / 32;
y := get(it, "y", .NUMBER).number / 32;
if entity_name == {
case "Player";
player_index := cast(int) get(it, "player_index", .NUMBER).number;
log("Player with player_index=%\n", player_index);
case "Snake";
snake_color := cast(int) get(it, "snake_color", .NUMBER).number;
log("Snake with snake_color=%\n", snake_color);
case;
//...
}
}
log("\n");
}
generic_printing :: () {
// We want to write JSON with arbitrary structure.
// Create and initialize object
root_obj: JSON_Object;
root := json_value(*root_obj);
// Add music index to object, in certain cases
should_add_music := true;
if should_add_music {
json_set(*root_obj, "music_index", .{type=.NUMBER, number=3});
}
// Create an array of values
temp: [..] JSON_Value;
junk := JSON_Value.{type=.STRING, str="junk"};
array_add(*temp, junk);
array_add(*temp, junk);
// Create json array value
array := JSON_Value.{type=.ARRAY};
array.array = temp;
// Add array to object
json_set(*root_obj, "junk_array", array);
// Print result
json_string := json_write_string(root);
log("Generic_printing result:\n%\n\n", json_string);
//json_write_file("level.json", root);
}
rename_by_note :: () {
// Sometimes the JSON we are parsing contains members with names we cannot use or don´t wanna.
Message :: struct {
value: string;
_context: struct { // We cannot use "context" because it´s a reserved keyword in Jai.
channel: int;
parent: int;
} @JsonName(context) // This member in JSON is "context" but we need to have it as "_context". So we can use the JsonName note to encode and decode it as "context".
}
json := #string DONE
{
"value": "Hello!",
"context": {
"channel": 1,
"parent": 897820
}
}
DONE
success, message_decoded := json_parse_string(json, Message, ignore_unknown = false);
assert(success, "Could not decode message!");
log("Rename_by_note decode result:\n%\n\n", message_decoded);
assert(message_decoded._context.channel == 1);
message_encoded := json_write_string(message_decoded);
log("Rename_by_note encode result:\n%\n\n", message_encoded);
assert(message_encoded.count != 0);
assert(find_index_from_left(message_encoded, "_context") == -1);
}
custom_rename_procedure :: () {
// We can also pass a custom rename procedure for renaming certain members.
rename_to_upper :: (member: *Type_Info_Struct_Member) -> string {
return to_upper_copy(member.name,,temp);
}
player := Entity.{
name="Player",
x = 10,
y = 50
};
player_encoded := json_write_string(player, rename=rename_to_upper);
assert(player_encoded.count != 0);
log("encoded with rename_to_upper:\n%\n\n", player_encoded);
assert(find_index_from_left(player_encoded, "name") == -1);
assert(find_index_from_left(player_encoded, "NAME") != -1);
success, player_decoded := json_parse_string(player_encoded, Entity, ignore_unknown = false, rename = rename_to_upper);
assert(success);
log("decoded with rename_to_upper:\n%\n\n", player_decoded);
assert(player_decoded.name == "Player");
}
decode_into_struct_with_using :: () {
Coordinates :: struct {
x: int;
y: int;
}
Tile :: struct {
color: string;
using coordinates: Coordinates;
}
JSON :: #string END
{
"color": "green",
"x": 1,
"y": 3
}
END
success, tile := json_parse_string(JSON, Tile, ignore_unknown = false);
assert(success);
log("Decoded tile: %", tile);
assert(tile.color == "green");
assert(tile.x == 1);
assert(tile.y == 3);
}
enum_errors :: () {
// Testing error messages when parsing invalid enum values
{
LEVEL_DATA_BROKEN_JSON := #string DONE
{"kind": ".VERY_HARD"}
DONE;
success, level := json_parse_string(LEVEL_DATA_BROKEN_JSON, LevelData, ignore_unknown=false);
assert(!success);
}
{
LEVEL_DATA_BROKEN_JSON := #string DONE
{"flags": "FLAG_A | FLAG_D"}
DONE;
success, level := json_parse_string(LEVEL_DATA_BROKEN_JSON, LevelData, ignore_unknown=false);
assert(!success);
}
}
#import "Basic";
#import "Hash_Table"; // To be able to iterate over node.object
#import "String"; // For to_upper_copy
#import "Compiler"; // For Type_Info_Struct_Member
Hash_Table :: #import "Hash_Table";

370
modules/Jaison/generic.jai Normal file
View File

@ -0,0 +1,370 @@
// Generic JSON parsing/writing functions. Result is always a JSON_Value,
// which is awful to read and even more awful to create for complex structures.
// But its useful for some cases where re-creating the whole JSON structure as
// custom Jai struct types is inconvenient or not possible.
// This generic interface was the very first part I wrote in Jai and hasnt been thorougly tested.
// Tread with care. There may be dragons.
JSON_Type :: enum u8 {
NULL :: 0;
BOOLEAN :: 1;
NUMBER :: 3;
STRING :: 2;
ARRAY :: 5;
OBJECT :: 4;
}
JSON_Value :: struct {
type: JSON_Type;
union {
boolean: bool;
number: float64;
str: string;
array: [] JSON_Value;
object: *JSON_Object;
};
}
JSON_Object :: Table(string, JSON_Value);
json_free :: (using val: JSON_Value) {
if #complete type == {
case .NULL;
case .BOOLEAN;
case .NUMBER;
case .STRING;
free(str);
case .ARRAY;
for array {
json_free(it);
}
array_free(array);
case .OBJECT;
for object {
free(it_index);
json_free(it);
}
deinit(object);
free(object);
}
}
json_parse_string :: (content: string) -> success: bool, JSON_Value {
if !content then return false, .{};
result, remainder, success := parse_value(content);
if !success return false, result;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder.count {
log_error("Unexpected trailing characters: %", remainder);
return false, result;
}
return true, result;
}
// For debug purposes
print_val :: (using val: JSON_Value) {
if #complete type == {
case .NULL;
print("null");
case .BOOLEAN;
print("%", boolean);
case .NUMBER;
print("%", number);
case .STRING;
print("\"%\"", str);
case .ARRAY;
print("[");
for array print_val(it);
print("]");
case .OBJECT;
print("%", (.*)object);
}
}
json_value :: (str: string) -> JSON_Value {
val: JSON_Value;
val.type =.STRING;
val.str = str;
return val;
}
json_value :: (obj: *JSON_Object) -> JSON_Value {
val: JSON_Value;
val.type =.OBJECT;
val.object = obj;
return val;
}
json_set_null :: (val: *JSON_Value) {
val.type = .NULL;
}
json_set :: (val: *JSON_Value, value: bool) {
val.type = .BOOLEAN;
val.boolean = value;
}
json_set :: (val: *JSON_Value, value: int) {
val.type = .NUMBER;
val.number = cast(float64) value;
}
json_set :: (val: *JSON_Value, value: float64) {
val.type = .NUMBER;
val.number = value;
}
json_set :: (val: *JSON_Value, value: string) {
val.type = .STRING;
val.str = value;
}
json_set :: (val: *JSON_Value, value: [] JSON_Value) {
val.type = .ARRAY;
val.array = value;
}
json_set :: (val: *JSON_Value, value: *JSON_Object) {
val.type = .OBJECT;
val.object = value;
}
json_write_json_value :: (builder: *String_Builder, using val: JSON_Value, indent_char := "\t", level := 0) {
if #complete type == {
case JSON_Type.NULL;
append(builder, "null");
case JSON_Type.BOOLEAN;
append(builder, ifx boolean "true" else "false");
case JSON_Type.NUMBER;
print_item_to_builder(builder, number);
case JSON_Type.STRING;
json_append_escaped(builder, str);
case JSON_Type.ARRAY;
append(builder, "[");
for array {
if indent_char.count {
append(builder, "\n");
for 0..level append(builder, indent_char);
}
json_write_json_value(builder, it, indent_char, level + 1);
if it_index != array.count - 1 append(builder, ",");
}
if indent_char.count {
append(builder, "\n");
for 0..level-1 append(builder, indent_char);
}
append(builder, "]");
case JSON_Type.OBJECT;
append(builder, "{");
obj := object;
keys: [..] string;
defer array_free(keys);
array_reserve(*keys, obj.count);
for v, k: (.*)obj {
array_add(*keys, k);
}
intro_sort(keys, compare);
for keys {
if indent_char.count {
append(builder, "\n");
for 0..level append(builder, indent_char);
}
json_append_escaped(builder, it);
append(builder, ": ");
found, v := table_find(obj, it);
assert(found, "Missing table value %", it);
json_write_json_value(builder, v, indent_char, level + 1);
if it_index != obj.count - 1 append(builder, ",");
}
if indent_char.count {
append(builder, "\n");
for 0..level-1 append(builder, indent_char);
}
append(builder, "}");
}
}
json_set :: (obj: *JSON_Object, path: string, val: JSON_Value) -> bool {
dotpos := find_index_from_left(path, #char ".");
if dotpos == -1 {
table_set(obj, path, val);
return true;
}
next := slice(path, 0, dotpos);
remainder := advance(path, dotpos + 1);
if !next.count return false;
if !remainder.count return false;
success, next_value := table_find(obj, next);
next_obj: *JSON_Object;
if success {
if next_value.type != JSON_Type.OBJECT return false;
next_obj = xx next_value.object;
} else {
next_obj = cast(*JSON_Object) alloc(size_of(JSON_Object));
memset(next_obj, 0, size_of(JSON_Object));
next_value = json_value(next_obj);
table_add(obj, next, next_value);
}
return json_set(next_obj, remainder, val);
}
get_as :: (val: JSON_Value, $T: Type) -> T {
#insert #run () -> string {
if T == bool {
return #string END
assert(val.type == .BOOLEAN, "Expected a % but got %", T, val.type);
return val.boolean;
END;
} else if T == float || T == float64 {
return #string END
assert(val.type == .NUMBER, "Expected a % but got %", T, val.type);
return cast(T) val.number;
END;
} else if T == string {
return #string END
assert(val.type == .STRING, "Expected a % but got %", T, val.type);
return val.str;
END;
} else if T == [] JSON_Value {
return #string END
assert(val.type == .ARRAY, "Expected a % but got %", T, val.type);
return val.array;
END;
} else if T == JSON_Object {
return #string END
assert(val.type == .OBJECT, "Expected a % but got %", T, val.type);
return (.*)val.object;
END;
} else {
compiler_report("Unsupported type");
return "";
}
}();
}
#scope_module
parse_value :: (to_parse: string) -> JSON_Value, remainder: string, success: bool {
result: JSON_Value;
remainder := trim_left(to_parse, WHITESPACE_CHARS);
success := false;
if remainder[0] == {
case #char "n";
remainder, success = expect_and_slice(remainder, "null");
if !success return result, remainder, false;
json_set_null(*result);
result.type = JSON_Type.NULL;
return result, remainder, true;
case #char "t";
remainder, success = expect_and_slice(remainder, "true");
if !success return result, remainder, false;
json_set(*result, true);
return result, remainder, true;
case #char "f";
remainder, success = expect_and_slice(remainder, "false");
if !success return result, remainder, false;
json_set(*result, false);
return result, remainder, true;
case #char "\"";
str: string;
str, remainder, success = parse_string(remainder);
json_set(*result, str);
case #char "[";
result.type = JSON_Type.ARRAY;
result.array, remainder, success = parse_array(remainder);
case #char "{";
obj := cast(*JSON_Object) alloc(size_of(JSON_Object));
(.*)obj, remainder, success = parse_object(remainder);
result = json_value(obj);
case;
result.type = JSON_Type.NUMBER;
result.number, success, remainder = string_to_float64(remainder);
}
return result, remainder, success;
}
parse_array:: (str: string) -> result: [] JSON_Value, remainder: string, success: bool {
assert(str[0] == #char "[", "Invalid object start %", str);
remainder := advance(str);
result: [..] JSON_Value;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] == #char "]" {
remainder = advance(remainder);
return result, remainder, true;
}
while true {
value: JSON_Value;
success: bool;
value, remainder, success = parse_value(remainder);
if !success return result, remainder, false;
array_add(*result, value);
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char "," break;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
}
if remainder[0] != #char "]" return result, remainder, false;
remainder = advance(remainder);
return result, remainder, true;
}
parse_object :: (str: string) -> result: JSON_Object, remainder: string, success: bool {
assert(str[0] == #char "{", "Invalid object start %", str);
remainder := advance(str);
result: JSON_Object;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] == #char "}" {
remainder = advance(remainder);
return result, remainder, true;
}
init(*result, 32);
while true {
if remainder[0] != #char "\"" return result, remainder, false;
key: string;
value: JSON_Value;
success: bool;
key, remainder, success = parse_string(remainder);
if !success return result, remainder, false;
existing := table_find_pointer(*result, key);
if existing return result, remainder, false;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char ":" return result, remainder, false;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
value, remainder, success = parse_value(remainder);
if !success return result, remainder, false;
table_add(*result, key, value);
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char "," break;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
}
if remainder[0] != #char "}" return result, remainder, false;
remainder = advance(remainder);
return result, remainder, true;
}
#import "Compiler";

219
modules/Jaison/module.jai Normal file
View File

@ -0,0 +1,219 @@
// This file contains just the JSON serialization functions. See generic.jai and typed.jai for parse fuctions.
// Generates a JSON string from either a JSON_Value or any custom type.
// "indent_char" does what it says on the tin.
// "ignore" is only used for custom types to determine which properties of your custom type should be serialized.
// The default ignore function ignores all struct members that have the note @JsonIgnore.
// "rename" is used for renaming certain members.
// It gets called with the Type_Info_Struct_Member and must return the new name of the field.
// The default procedure rename members by their @JsonName note. Eg: @JsonName(renamed_member).
json_write_string :: (value: $T, indent_char := "\t", ignore := ignore_by_note, rename := rename_by_note) -> string {
builder: String_Builder;
defer free_buffers(*builder);
json_append_value(*builder, value, indent_char, ignore, rename);
return builder_to_string(*builder);
}
json_append_value :: (builder: *String_Builder, val: $T, indent_char := "\t", ignore := ignore_by_note, rename := rename_by_note) {
#if T == JSON_Value {
json_write_json_value(builder, val, indent_char);
} else {
info := type_info(T);
json_write_native(builder, *val, info, indent_char, ignore, rename);
}
}
// This function is useful if you have a JSON template string and just want to
// safely insert a value without having to replicate the full json structure in Jai.
// The return value does NOT include quotes around the string.
//
// Example:
// JSON_TEMPLATE :: #string END
// {
// "complicated": {
// "json": {
// "structure": {
// "for_a_stupid_api": {
// "that_needs": [
// {"a_deeply_nested_value": "%1"}
// ]
// }
// }
// }
// }
// }
// END
// escaped_value := json_escape_string(my_unsafe_value);
// defer free(escaped_value);
// json_str := print(JSON_TEMPLATE, escaped_value);
json_escape_string :: (str: string) -> string {
builder: String_Builder;
defer free_buffers(*builder);
json_append_escaped(*builder, str);
escaped := builder_to_string(*builder);
return escaped;
}
Ignore_Proc :: #type (member: *Type_Info_Struct_Member) -> bool;
Rename_Proc :: #type (member: *Type_Info_Struct_Member) -> string;
ignore_by_note :: (member: *Type_Info_Struct_Member) -> bool {
for note: member.notes {
if note == "JsonIgnore" return true;
}
return false;
}
rename_by_note :: (member: *Type_Info_Struct_Member) -> string {
for note: member.notes {
if !begins_with(note, "JsonName(") continue;
if note.count <= 10 || note[note.count-1] != #char ")" {
log_error("Invalid JsonName note format. Expected a name in parenthesis, but the note was \"%\".", note);
continue;
}
return slice(note, 9, note.count-10);
}
return member.name;
}
#scope_module
WHITESPACE_CHARS :: " \t\n\r";
#load "generic.jai";
#load "typed.jai";
json_append_escaped :: (builder: *String_Builder, str: string) {
remaining := str;
next_pos := index_of_illegal_string_char(remaining);
append(builder, "\"");
while (next_pos >= 0) {
append(builder, slice(remaining, 0, next_pos));
if remaining[next_pos] == {
case #char "\\";
append(builder, "\\\\");
case #char "\"";
append(builder, "\\\"");
case #char "\n";
append(builder, "\\n");
case #char "\r";
append(builder, "\\r");
case #char "\t";
append(builder, "\\t");
case;
// ToDo: handle illegal multi-byte characters
// print("Escaping: %\n\n", slice(remaining, next_pos, remaining.count - next_pos));
print_to_builder(builder, "\\u%", formatInt(remaining[next_pos], base=16, minimum_digits=4));
}
remaining = advance(remaining, next_pos + 1);
next_pos = index_of_illegal_string_char(remaining);
}
append(builder, remaining);
append(builder, "\"");
}
index_of_illegal_string_char :: (str: string) -> s64 {
for 0..str.count - 1 {
if str[it] == #char "\\" || str[it] == #char "\"" || str[it] <= 0x1F {
return it;
}
}
return -1;
}
expect_and_slice :: (str: string, expected: string) -> remainder: string, success: bool {
if str.count < expected.count || !equal(slice(str, 0, expected.count), expected) {
log_error("Unexpected token. Expected \"%\" but got: %", expected, str);
return str, false;
}
remainder := advance(str, expected.count);
return remainder, true;
}
parse_string :: (str: string) -> result: string, remainder: string, success: bool {
assert(str[0] == #char "\"", "Invalid string start %", str);
inside := advance(str);
needsUnescape := false;
while inside[0] != #char "\"" {
if inside.count < 2 return "", str, false;
if inside[0] == #char "\\" {
needsUnescape = true;
if inside.count < 2 return "", str, false;
advance(*inside);
}
advance(*inside);
}
length := inside.data - str.data - 1;
result := slice(str, 1, length);
if needsUnescape {
success: bool;
result, success = unescape(result);
if !success return "", str, false;
} else {
result = copy_string(result);
}
remainder := slice(str, length + 2, str.count - length - 2);
return result, remainder, true;
}
unescape :: (str: string) -> result: string, success: bool {
result := alloc_string(str.count);
rc := 0;
for i: 0..str.count-1 {
if str[i] != #char "\\" {
// Check for invalid characters for JSON
if str[i] < 0x20 return "", false;
result[rc] = str[i];
rc += 1;
} else {
if i == str.count - 1 return "", false;
i += 1;
if str[i] == {
case #char "\""; #through;
case #char "/"; #through;
case #char "\\";
result[rc] = str[i];
rc += 1;
case #char "b";
result[rc] = 0x08;
rc += 1;
case #char "f";
result[rc] = 0x0c;
rc += 1;
case #char "n";
result[rc] = #char "\n";
rc += 1;
case #char "r";
result[rc] = #char "\r";
rc += 1;
case #char "t";
result[rc] = #char "\t";
rc += 1;
case #char "u";
if i + 4 >= str.count return "", false;
unicode_char, success := parse_unicode(slice(str, i + 1, 4));
if !success return "", false;
utf8_len := encode_utf8(unicode_char, *(result.data[rc]));
rc += utf8_len;
i += 4;
case;
return "", false;
}
}
}
result.count = rc;
return result, true;
}
#import "Basic";
#import "String";
#import "Hash_Table";
#import,dir "./unicode_utils";
#import "IntroSort";

63
modules/Jaison/readme.md Normal file
View File

@ -0,0 +1,63 @@
# JSON serialization / deserialization module for Jai
*Attention: This version requires Jai beta 0.1.080!*
Use `v1.0.0` for older betas.
This module offers two interfaces:
* one uses a "generic tree" built from `JSON_Value`
* the other is a typed version that serializes / deserializes your custom data structures.
The generic `JSON_Value` graphs are a pain to consume and even worse to produce by hand.
But they allow you to parse any JSON, even if you dont know the structure (or cant reproduce it in Jai because it varies).
The typed interface is what you want for most cases.
## Parsing / Deserialization
Parsing is as simple as:
```Jai
// Typed version:
success, result := json_parse_string(json_str, Your_Type_To_Parse_Into);
// … or if you want to get a generic structure back:
success, result := json_parse_string(json_str);
```
There are also a convenience functions for parsing if the JSON data is in a file:
```Jai
success, result := json_parse_file(json_filename, Your_Type_To_Parse_Into);
// … or
success, result := json_parse_file(json_filename);
```
See [`typed.jai`](./typed.jai) and [`generic.jai`](./generic.jai) for details and additional options.
### Mixed typed and generic data
If you dont know the structure of some subfield of your `Your_Type_To_Parse_Into` structure, but still want to get these values from the JSON data,
you can declare these fields as the generic type `JSON_Value` or `*JSON_Value` and the generic parse function will take over at that point:
```
Your_Type_To_Parse_Into :: struct {
name: string;
age: int;
something_we_dont_know_much_about: *JSON_Value; // Whatever structure hides in the JSON, it will be parsed into JSON_Value.
}
```
## Printing / Serialization
Generating a string works the same for both interfaces:
```Jai
json_str := json_write_string(my_value);
```
where `my_value` is either a `JSON_Value` or any other data structure.
See [`module.jai`](./module.jai) for details and additional parameters.
## Dependencies
This module uses [the `unicode_utils` module](https://github.com/rluba/jai-unicode), which is included as a submodule.

625
modules/Jaison/typed.jai Normal file
View File

@ -0,0 +1,625 @@
// Parse a JSON string into the given Type.
// All members of Type that are not present in the JSON are kept at their default values.
// All fields in the JSON that have no corresponding member in Type are ignored by default
// but you can pass ignore_unknown = false to fail instead.
json_parse_string :: (content: string, $T: Type, ignore_unknown := true, rename := rename_by_note) -> success: bool, T {
result: T;
if !content then return false, result;
info := type_info(T);
remainder, success := parse_value(content, cast(*u8)*result, info, ignore_unknown, "", rename=rename);
if !success return false, result;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder.count {
log_error("Unexpected trailing characters: %", remainder);
return false, result;
}
return true, result;
}
json_parse_file :: (filename: string, $T: Type, ignore_unknown := true, rename := rename_by_note) -> success: bool, T {
file_data, success := read_entire_file(filename);
result: T;
if !success {
log_error("Could not read file: %", filename);
return false, result;
}
defer free(file_data);
if (context.log_level >= .VERBOSE) {
log("Read file: %", success);
}
success, result = json_parse_string(file_data, T, ignore_unknown, rename=rename);
return success, result;
}
json_write_native :: (builder: *String_Builder, data: *void, info: *Type_Info, indent_char := "\t", ignore := ignore_by_note, rename := rename_by_note, level := 0) {
if info.type == {
case .BOOL;
append(builder, ifx (.*)(cast(*bool) data) "true" else "false");
case .INTEGER; #through;
case .FLOAT;
any_val: Any;
any_val.type = info;
any_val.value_pointer = data;
print_item_to_builder(builder, any_val);
case .ENUM;
any_val: Any;
any_val.type = info;
any_val.value_pointer = data;
append(builder, #char "\"");
print_item_to_builder(builder, any_val);
append(builder, #char "\"");
case .STRING;
json_append_escaped(builder, (.*)(cast(*string) data));
case .ARRAY;
info_array := cast(*Type_Info_Array) info;
element_size := info_array.element_type.runtime_size;
assert(element_size != -1);
stride := element_size;
array_data := data;
array_count := info_array.array_count;
if info_array.array_count == -1 {
array_count = (.*) cast(*s64) data;
array_dest: **void = data + 8;
array_data = (.*) array_dest;
}
append(builder, "[");
if array_data {
if indent_char.count {
append(builder, "\n");
for 0..level append(builder, indent_char);
}
for 0..array_count-1 {
json_write_native(builder, array_data, info_array.element_type, indent_char, ignore, rename, level + 1);
if it != array_count - 1 append(builder, ",");
array_data += stride;
}
}
if indent_char.count {
append(builder, "\n");
for 0..level-1 append(builder, indent_char);
}
append(builder, "]");
case .STRUCT;
struct_info := cast(*Type_Info_Struct) info;
if is_generic_json_value(info) {
value := cast(*JSON_Value) data;
json_write_json_value(builder, (.*)value, indent_char, level);
} else {
append(builder, #char "{");
first := true;
json_write_native_members(builder, data, struct_info.members, indent_char, ignore, rename, level, *first);
if indent_char.count {
append(builder, "\n");
for 0..level-1 append(builder, indent_char);
}
append(builder, "}");
}
case .POINTER;
ptr_info := cast(*Type_Info_Pointer) info;
ptr := (.*) cast(**void) data;
if ptr {
json_write_native(builder, ptr, ptr_info.pointer_to, indent_char, ignore, rename, level);
} else {
append(builder, "null");
}
case;
assert(false, "Unsupported type: %", info.type);
}
}
#scope_file
json_write_native_members :: (builder: *String_Builder, data: *void, members: [] Type_Info_Struct_Member, indent_char := "\t", ignore := ignore_by_note, rename: Rename_Proc, level := 0, first: *bool) {
for * member: members {
if member.flags & .CONSTANT continue;
if ignore(member) continue;
if (member.type.type == .STRUCT && member.flags & .USING) {
info := cast(*Type_Info_Struct) member.type;
json_write_native_members(builder, data + member.offset_in_bytes, info.members, indent_char, ignore, rename, level, first);
} else {
if !(.*)first append(builder, ",");
(.*)first = false;
if indent_char.count {
append(builder, "\n");
for 0..level append(builder, indent_char);
}
renamed_name := rename(member);
name := ifx renamed_name.count > 0 renamed_name else member.name;
json_append_escaped(builder, name);
append(builder, ": ");
json_write_native(builder, data + member.offset_in_bytes, member.type, indent_char, ignore, rename, level + 1);
}
}
}
is_generic_json_value_or_pointer_to_it :: (info: *Type_Info) -> bool {
value_info := info;
if info.type == .POINTER {
pointer_info := cast(*Type_Info_Pointer) info;
value_info = pointer_info.pointer_to;
}
return is_generic_json_value(info);
}
is_generic_json_value :: (info: *Type_Info) -> bool {
return info == type_info(JSON_Value);
}
parse_value :: (to_parse: string, slot: *u8, info: *Type_Info, ignore_unknown: bool, field_name: string, rename: Rename_Proc) -> remainder: string, success: bool {
remainder := trim_left(to_parse, WHITESPACE_CHARS);
success := true;
prepare_slot :: (expected_type: Type_Info_Tag, info: *Type_Info, slot: *u8, to_parse: string) -> *u8, success: bool, is_generic: bool, info: *Type_Info {
value_info := info;
if info.type == .POINTER {
pointer_info := cast(*Type_Info_Pointer) info;
value_info = pointer_info.pointer_to;
}
if info.type == .ENUM {
info_enum := cast(*Type_Info_Enum)info;
value_info = info_enum.internal_type;
}
is_generic := is_generic_json_value(value_info);
if !is_generic && value_info.type != expected_type {
teaser := to_parse;
if teaser.count > 50 teaser.count = 50;
builder: String_Builder;
print_type_to_builder(*builder, info);
type_name := builder_to_string(*builder,, temp);
log_error("Cannot parse % value into type \"%\". Remaining input is: %…", expected_type, type_name, teaser);
return null, false, false, value_info;
}
if info.type == .POINTER {
value_slot := alloc(value_info.runtime_size);
initializer: (*void) #no_context;
if value_info.type == .STRUCT {
struct_info := cast(*Type_Info_Struct) value_info;
initializer = struct_info.initializer;
}
if initializer {
initializer(value_slot);
} else {
memset(value_slot, 0, value_info.runtime_size);
}
(.*)cast(**u8)slot = value_slot;
return value_slot, true, is_generic, value_info;
} else {
return slot, true, is_generic, value_info;
}
}
is_generic: bool;
if remainder[0] == {
case #char "n";
remainder, success = expect_and_slice(remainder, "null");
if !success return remainder, false;
if slot {
if info.type == .POINTER {
(.*)cast(**void) slot = null;
} else {
builder: String_Builder;
print_type_to_builder(*builder, info);
type_name := builder_to_string(*builder,, temp);
log_error("Got NULL value for non-pointer type \"%\" of field \"%\". Keeping default value instead.", type_name, field_name);
}
}
return remainder, true;
case #char "t";
remainder, success = expect_and_slice(remainder, "true");
if !success return remainder, false;
if slot {
value_slot: *u8;
value_slot, success, is_generic = prepare_slot(.BOOL, info, slot, to_parse);
if success {
if is_generic {
json_set(cast(*JSON_Value)value_slot, true);
} else {
(.*)cast(*bool)value_slot = true;
}
}
}
case #char "f";
remainder, success = expect_and_slice(remainder, "false");
if !success return remainder, false;
if slot {
value_slot: *u8;
value_slot, success, is_generic = prepare_slot(.BOOL, info, slot, to_parse);
if success {
if is_generic {
json_set(cast(*JSON_Value)value_slot, false);
} else {
(.*)cast(*bool)value_slot = false;
}
}
}
case #char "\"";
if slot && info && info.type == .ENUM {
info_enum := cast(*Type_Info_Enum)info;
value_slot: *u8;
value_slot, success, is_generic = prepare_slot(.INTEGER, info_enum.internal_type, slot, to_parse);
remainder, success = parse_enum_string(remainder, value_slot, info_enum);
} else {
value: string;
value, remainder, success = parse_string(remainder);
stored := false;
defer if !stored free(value);
if success && slot {
value_slot: *u8;
value_slot, success, is_generic = prepare_slot(.STRING, info, slot, to_parse);
if success {
if is_generic {
json_set(cast(*JSON_Value)value_slot, value);
} else {
(.*)cast(*string)value_slot = value;
}
stored = true;
}
}
}
case #char "[";
value_slot: *u8;
value_info: *Type_Info;
if slot {
value_slot, success, is_generic, value_info = prepare_slot(.ARRAY, info, slot, to_parse);
}
if success {
if is_generic {
value: [] JSON_Value;
value, remainder, success = parse_array(remainder);
json_set(cast(*JSON_Value)value_slot, value);
} else {
remainder, success = parse_array(remainder, value_slot, cast(*Type_Info_Array) value_info, ignore_unknown, rename=rename);
}
}
case #char "{";
value_slot: *u8;
value_info: *Type_Info;
if slot {
value_slot, success, is_generic, value_info = prepare_slot(.STRUCT, info, slot, to_parse);
}
if success {
if is_generic {
value := New(JSON_Object);
(.*)value, remainder, success = parse_object(remainder);
json_set(cast(*JSON_Value)value_slot, value);
} else {
remainder, success = parse_object(remainder, value_slot, cast(*Type_Info_Struct) value_info, ignore_unknown, rename=rename);
}
}
case;
if slot == null || info.type == .FLOAT || is_generic_json_value_or_pointer_to_it(info) {
float_value: float64;
float_value, success, remainder = string_to_float64(remainder);
if success && slot {
value_slot: *u8;
value_info: *Type_Info;
value_slot, success, is_generic, value_info = prepare_slot(.FLOAT, info, slot, to_parse);
if success {
if is_generic {
json_set(cast(*JSON_Value)value_slot, float_value);
} else {
if value_info.runtime_size == 4 {
((.*) cast(*float) slot) = cast(float) float_value;
} else {
assert(value_info.runtime_size == 8);
((.*) cast(*float64) slot) = float_value;
}
}
}
}
} else {
if slot {
value_slot: *u8;
value_info: *Type_Info;
value_slot, success, is_generic, value_info = prepare_slot(.INTEGER, info, slot, to_parse);
if success {
if is_generic {
int_value: s64;
int_value, success, remainder = string_to_int(remainder, T = s64);
if success {
json_set(cast(*JSON_Value)value_slot, int_value);
} else {
log_error("Could not parse \"%\" as an integer.", to_parse);
}
} else {
info_int := cast(*Type_Info_Integer) value_info;
success, remainder = parse_and_write_integer(info_int, value_slot, to_parse);
}
}
} else {
int_value: s64;
int_value, success, remainder = string_to_int(remainder, T = s64);
if !success {
log_error("Could not parse \"%\" as an integer.", to_parse);
}
}
}
}
return remainder, success;
}
parse_enum_string :: (str: string, slot: *u8, info_enum: *Type_Info_Enum) -> remainder: string, success: bool {
value, remainder, success := parse_string(str);
defer free(value);
if !success return remainder, false;
// Parse by members' names
normalize_enum_value :: inline (name: string) -> string #expand {
normalized := trim(name);
if normalized.count > info_enum.name.count && starts_with(normalized, info_enum.name) && normalized[info_enum.name.count] == #char "." {
normalized = slice(normalized, info_enum.name.count+1, normalized.count-info_enum.name.count-1);
} else if starts_with(normalized, ".") {
normalized = slice(normalized, 1, normalized.count-1);
}
return normalized;
}
int_info := info_enum.internal_type;
int_value: s64;
if info_enum.enum_type_flags & .FLAGS {
values := split(value, "|",, temp);
for v: values {
name := normalize_enum_value(v);
found_name := false;
for info_enum.names {
if name == it {
found_name = true;
int_value |= info_enum.values[it_index];
break;
}
}
if !found_name {
log_error("Enum \"%\" does not contain a member named \"%\".", info_enum.name, name);
success = false;
}
}
} else {
success = false;
name := normalize_enum_value(value);
for info_enum.names {
if name == it {
int_value = info_enum.values[it_index];
success = true;
break;
}
}
if !success {
log_error("Enum \"%\" does not contain a member named \"%\".", info_enum.name, name);
}
}
if success {
if int_info.signed {
valid, low, high := Reflection.range_check_and_store(int_value, int_info, slot);
if !valid {
log_error("The value '%' is out of range. (It must be between % and %.)", int_value, low, high);
return remainder, false;
}
} else {
valid, low, high := Reflection.range_check_and_store(cast(u64) int_value, int_info, slot);
if !valid {
log_error("The value '%' is out of range. (It must be between % and %.)", int_value, low, high);
return remainder, false;
}
}
}
return remainder, success;
}
parse_array :: (str: string, slot: *u8, info: *Type_Info_Array, ignore_unknown: bool, rename: Rename_Proc) -> remainder: string, success: bool {
element_size: int;
if slot {
element_size = info.element_type.runtime_size;
assert(element_size != -1, "Unknown element size");
}
assert(str[0] == #char "[", "Invalid object start %", str);
remainder := advance(str);
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] == #char "]" {
remainder = advance(remainder);
// @Robustness: Do we need to zero out the array?
return remainder, true;
}
if slot {
array: Resizable_Array;
initializer: (*void) #no_context;
if info.element_type.type == .STRUCT {
struct_info := cast(*Type_Info_Struct) info.element_type;
initializer = struct_info.initializer;
}
while true {
maybe_grow(*array, element_size);
element_data := array.data + array.count * element_size;
if initializer {
initializer(element_data);
} else {
memset(element_data, 0, element_size);
}
success: bool;
remainder, success = parse_value(remainder, element_data, info.element_type, ignore_unknown, "", rename=rename);
if !success return remainder, false;
array.count += 1;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char "," break;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
}
if info.array_type == .VIEW {
view := (cast(*Array_View_64) slot);
view.count = array.count;
view.data = array.data;
} else if info.array_count == -1 {
// Resizable array
(.*)(cast(*Resizable_Array) slot) = array;
} else {
// Fixed-size array
if (info.array_count != array.count) {
log_error("Expected array of size %, but found array of size %\n", info.array_count, array.count);
return remainder, false;
}
memcpy(slot, array.data, array.count * element_size);
}
} else {
while true {
success: bool;
remainder, success = parse_value(remainder, null, null, ignore_unknown, "", rename=rename);
if !success return remainder, false;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char "," break;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
}
}
if remainder[0] != #char "]" return remainder, false;
remainder = advance(remainder);
return remainder, true;
}
Member_Offset :: struct {
member: *Type_Info_Struct_Member;
offset_in_bytes: s64;
}
// This procedure is somewhat copied from Basic.get_field.
fill_member_table :: (table: *Table(string, Member_Offset), info: *Type_Info_Struct, rename: Rename_Proc, base_offset := 0) {
for * member: info.members {
offset := base_offset + member.offset_in_bytes;
name := rename(member);
assert(!table_find_pointer(table, name), "Redeclaration of member \"%\": % vs. %", name, (.*)member, (.*)table_find_pointer(table, name));
table_set(table, name, .{member, offset});
if (member.flags & .USING) && (member.type.type == .STRUCT) {
fill_member_table(table, cast(*Type_Info_Struct)member.type, rename, offset);
}
}
}
parse_object :: (str: string, slot: *u8, info: *Type_Info_Struct, ignore_unknown: bool, rename: Rename_Proc) -> remainder: string, success: bool {
assert(str[0] == #char "{", "Invalid object start %", str);
remainder := advance(str);
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] == #char "}" {
remainder = advance(remainder);
return remainder, true;
}
// @Speed: Building this table every time is pretty silly.
// We should probably either not build it at all or cache it somewhere.
member_table: Table(string, Member_Offset);
init(*member_table);
defer deinit(*member_table);
if info fill_member_table(*member_table, info, rename);
while true {
if remainder[0] != #char "\"" return remainder, false;
key: string;
success: bool;
key, remainder, success = parse_string(remainder);
if !success return remainder, false;
defer free(key);
member_found, member_offset := table_find(*member_table, key);
member_slot: *u8;
member_info: *Type_Info;
if member_found {
member_slot = slot + member_offset.offset_in_bytes;
member_info = member_offset.member.type;
} else if !ignore_unknown {
log_error("Missing member % in %", key, (.*)info);
return remainder, false;
}
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char ":" return remainder, false;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
remainder, success = parse_value(remainder, member_slot, member_info, ignore_unknown, key, rename);
if !success return remainder, false;
remainder = trim_left(remainder, WHITESPACE_CHARS);
if remainder[0] != #char "," break;
remainder = advance(remainder);
remainder = trim_left(remainder, WHITESPACE_CHARS);
}
if remainder[0] != #char "}" return remainder, false;
remainder = advance(remainder);
return remainder, true;
}
parse_and_write_integer :: (info: *Type_Info_Integer, pointer: *void, string_value: string) -> bool, remainder: string {
if info.signed {
success, remainder := parse_and_write_integer(info, pointer, string_value, signed = true);
return success, remainder;
} else {
success, remainder := parse_and_write_integer(info, pointer, string_value, signed = false);
return success, remainder;
}
}
parse_and_write_integer :: (info: *Type_Info_Integer, pointer: *void, string_value: string, $signed: bool) -> bool, remainder: string {
#if signed {
Int_Type :: s64;
} else {
Int_Type :: u64;
}
int_value, int_success, remainder := string_to_int(string_value, T = Int_Type);
if !int_success {
#if signed {
log_error("Could not parse \"%\" as an integer.", string_value);
} else {
log_error("Could not parse \"%\" as an unsigned integer.", string_value);
}
return false, remainder;
}
valid, low, high := Reflection.range_check_and_store(int_value, info, pointer);
if !valid {
log_error("The value '%' is out of range. (It must be between % and %.)", int_value, low, high);
return false, remainder;
}
return true, remainder;
}
Reflection :: #import "Reflection";

View File

@ -0,0 +1,59 @@
is_utf_cont :: inline (b: u8) -> bool {
return (b & 0xc0) == 0x80;
}
parse_unicode :: (str: string) -> result: u16, success: bool {
val, success, remainder := string_to_int(str, base = 16);
if !success || val > 0xFFFF || remainder.count return 0, false;
return xx val, true;
}
encode_utf8 :: (val: u16, result: *u8) -> len: u8 {
if val & 0xF800 {
result[0] = xx (0xE0 | ((val & 0xF000) >> 12));
result[1] = xx (0x80 | ((val & 0x0FC0) >> 6));
result[2] = xx (0x80 | (val & 0x003F));
return 3;
} else if val & 0x0F80 {
result[0] = xx (0xC0 | ((val & 0x0FC0) >> 6));
result[1] = xx (0x80 | (val & 0x003F));
return 2;
} else {
result[0] = xx (val & 0x7F);
return 1;
}
}
is_valid_utf8 :: (str: string) -> valid:bool {
for i: 0..str.count-1 {
cur := str[i];
if cur >= 0x80 {
// Must be between 0xc2 and 0xf4 inclusive to be valid
if (cur - 0xc2) > (0xf4 - 0xc2) return false;
if cur < 0xe0 { // 2-byte sequence
if i + 1 >= str.count || !is_utf_cont(str[i+1]) return false;
i += 1;
} else if cur < 0xf0 { // 3-byte sequence
if i + 2 >= str.count || !is_utf_cont(str[i+1]) || !is_utf_cont(str[i+2]) return false;
// Check for surrogate chars
if cur == 0xed && str[i+1] > 0x9f return false;
// ToDo: Check if total >= 0x800
// uc = ((uc & 0xf)<<12) | ((*str & 0x3f)<<6) | (str[1] & 0x3f);
i += 2;
} else { // 4-byte sequence
if i + 3 >= str.count || !is_utf_cont(str[i+1]) || !is_utf_cont(str[i+2]) || !is_utf_cont(str[i+3]) return false;
// Make sure its in valid range (0x10000 - 0x10ffff)
if cur == 0xf0 && str[i + 1] < 0x90 return false;
if cur == 0xf4 && str[i + 1] > 0x8f return false;
i += 3;
}
}
}
return true;
}
#scope_file
#import "Basic";

Binary file not shown.

View File

@ -0,0 +1,32 @@
//
// Bindings for the dr_mp3 + dr_flac wrapper compiled in source/build.sh.
//
// Each decode_* call hands back a heap-allocated s16 PCM buffer; the caller
// owns it and must release it with `decoder_free()`. The buffer is
// interleaved (L,R,L,R,…) for stereo, or mono for 1ch.
//
#scope_module
#if OS == .LINUX decoders :: #library,no_dll "linux/decoders";
#if OS == .MACOS decoders :: #library,no_dll "macos/decoders";
#scope_export
decode_mp3 :: (
data: *void,
data_size: u64,
out_channels: *u32,
out_sample_rate: *u32,
out_total_pcm_frames: *u64,
) -> *s16 #foreign decoders "player_decode_mp3";
decode_flac :: (
data: *void,
data_size: u64,
out_channels: *u32,
out_sample_rate: *u32,
out_total_pcm_frames: *u64,
) -> *s16 #foreign decoders "player_decode_flac";
decoder_free :: (p: *void) #foreign decoders "player_decoder_free";

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# Compile dr_mp3 + dr_flac wrapper into a static library.
#
# Output: ../linux/decoders.a (or ../macos/decoders.a)
#
# Run from anywhere — this script cd's to its own directory first.
#
set -euo pipefail
cd "$(dirname "$0")"
CC="${CC:-clang}"
case "$(uname -s)" in
Linux*) OUT_DIR=../linux ;;
Darwin*) OUT_DIR=../macos ;;
*) echo "unsupported platform"; exit 1 ;;
esac
mkdir -p "$OUT_DIR"
echo "compiling decoders.c..."
"$CC" -O2 -fPIC -Wno-everything -c -o decoders.o decoders.c
ar rcs "$OUT_DIR/decoders.a" decoders.o
rm decoders.o
echo "wrote $OUT_DIR/decoders.a"

View File

@ -0,0 +1,74 @@
//
// Tiny C wrapper around dr_mp3 + dr_flac. Each function decodes a buffer of
// MP3/FLAC bytes to interleaved s16 PCM. The caller owns the returned
// pointer and must release it with player_decoder_free().
//
// Compiled to a static lib (decoders.a) by lib/build_decoders.sh. The
// metaprogram in build.jai re-runs the script if the .a is missing or the
// sources are newer.
//
#define DR_MP3_IMPLEMENTATION
#define DR_MP3_NO_STDIO
#include "dr_mp3.h"
#define DR_FLAC_IMPLEMENTATION
#define DR_FLAC_NO_STDIO
#include "dr_flac.h"
#include <stdint.h>
#include <stdlib.h>
int16_t* player_decode_mp3(
const void* data, size_t data_size,
uint32_t* out_channels,
uint32_t* out_sample_rate,
uint64_t* out_total_pcm_frames
) {
drmp3_config config;
config.channels = 0;
config.sampleRate = 0;
drmp3_uint64 total_frames = 0;
drmp3_int16* pcm = drmp3_open_memory_and_read_pcm_frames_s16(
data, data_size,
&config,
&total_frames,
NULL // default allocator
);
if (!pcm) return NULL;
*out_channels = config.channels;
*out_sample_rate = config.sampleRate;
*out_total_pcm_frames = total_frames;
return pcm;
}
int16_t* player_decode_flac(
const void* data, size_t data_size,
uint32_t* out_channels,
uint32_t* out_sample_rate,
uint64_t* out_total_pcm_frames
) {
unsigned int channels = 0;
unsigned int sample_rate = 0;
drflac_uint64 total_frames = 0;
drflac_int16* pcm = drflac_open_memory_and_read_pcm_frames_s16(
data, data_size,
&channels,
&sample_rate,
&total_frames,
NULL
);
if (!pcm) return NULL;
*out_channels = channels;
*out_sample_rate = sample_rate;
*out_total_pcm_frames = total_frames;
return pcm;
}
void player_decoder_free(void* p) {
free(p);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,145 @@
//
// This file was auto-generated using the following command:
//
// jai generate.jai
//
STBI_VERSION :: 1;
STBI :: enum u32 {
default :: 0;
grey :: 1;
grey_alpha :: 2;
rgb :: 3;
rgb_alpha :: 4;
STBI_default :: default;
STBI_grey :: grey;
STBI_grey_alpha :: grey_alpha;
STBI_rgb :: rgb;
STBI_rgb_alpha :: rgb_alpha;
}
//
// load image by filename, open file, or memory buffer
//
stbi_io_callbacks :: struct {
read: #type (user: *void, data: *u8, size: s32) -> s32 #c_call; // fill 'data' with 'size' bytes. return number of bytes actually read
skip: #type (user: *void, n: s32) -> void #c_call; // skip the next 'n' bytes, or 'unget' the last -n bytes if negative
eof: #type (user: *void) -> s32 #c_call; // returns nonzero if we are at end of file/data
}
////////////////////////////////////
//
// 8-bits-per-channel interface
//
stbi_load_from_memory :: (buffer: *u8, len: s32, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u8 #foreign stb_image;
stbi_load_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u8 #foreign stb_image;
stbi_load :: (filename: *u8, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u8 #foreign stb_image;
stbi_load_from_file :: (f: *FILE, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u8 #foreign stb_image;
stbi_load_gif_from_memory :: (buffer: *u8, len: s32, delays: **s32, x: *s32, y: *s32, z: *s32, comp: *s32, req_comp: s32) -> *u8 #foreign stb_image;
////////////////////////////////////
//
// 16-bits-per-channel interface
//
stbi_load_16_from_memory :: (buffer: *u8, len: s32, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u16 #foreign stb_image;
stbi_load_16_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u16 #foreign stb_image;
stbi_load_16 :: (filename: *u8, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u16 #foreign stb_image;
stbi_load_from_file_16 :: (f: *FILE, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *u16 #foreign stb_image;
stbi_loadf_from_memory :: (buffer: *u8, len: s32, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *float #foreign stb_image;
stbi_loadf_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *float #foreign stb_image;
stbi_loadf :: (filename: *u8, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *float #foreign stb_image;
stbi_loadf_from_file :: (f: *FILE, x: *s32, y: *s32, channels_in_file: *s32, desired_channels: s32) -> *float #foreign stb_image;
stbi_hdr_to_ldr_gamma :: (gamma: float) -> void #foreign stb_image;
stbi_hdr_to_ldr_scale :: (scale: float) -> void #foreign stb_image;
stbi_ldr_to_hdr_gamma :: (gamma: float) -> void #foreign stb_image;
stbi_ldr_to_hdr_scale :: (scale: float) -> void #foreign stb_image;
// stbi_is_hdr is always defined, but always returns false if STBI_NO_HDR
stbi_is_hdr_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void) -> s32 #foreign stb_image;
stbi_is_hdr_from_memory :: (buffer: *u8, len: s32) -> s32 #foreign stb_image;
stbi_is_hdr :: (filename: *u8) -> s32 #foreign stb_image;
stbi_is_hdr_from_file :: (f: *FILE) -> s32 #foreign stb_image;
// get a VERY brief reason for failure
// on most compilers (and ALL modern mainstream compilers) this is threadsafe
stbi_failure_reason :: () -> *u8 #foreign stb_image;
// free the loaded image -- this is just free()
stbi_image_free :: (retval_from_stbi_load: *void) -> void #foreign stb_image;
// get image dimensions & components without fully decoding
stbi_info_from_memory :: (buffer: *u8, len: s32, x: *s32, y: *s32, comp: *s32) -> s32 #foreign stb_image;
stbi_info_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void, x: *s32, y: *s32, comp: *s32) -> s32 #foreign stb_image;
stbi_is_16_bit_from_memory :: (buffer: *u8, len: s32) -> s32 #foreign stb_image;
stbi_is_16_bit_from_callbacks :: (clbk: *stbi_io_callbacks, user: *void) -> s32 #foreign stb_image;
stbi_info :: (filename: *u8, x: *s32, y: *s32, comp: *s32) -> s32 #foreign stb_image;
stbi_info_from_file :: (f: *FILE, x: *s32, y: *s32, comp: *s32) -> s32 #foreign stb_image;
stbi_is_16_bit :: (filename: *u8) -> s32 #foreign stb_image;
stbi_is_16_bit_from_file :: (f: *FILE) -> s32 #foreign stb_image;
// for image formats that explicitly notate that they have premultiplied alpha,
// we just return the colors as stored in the file. set this flag to force
// unpremultiplication. results are undefined if the unpremultiply overflow.
stbi_set_unpremultiply_on_load :: (flag_true_if_should_unpremultiply: s32) -> void #foreign stb_image;
// indicate whether we should process iphone images back to canonical format,
// or just pass them through "as-is"
stbi_convert_iphone_png_to_rgb :: (flag_true_if_should_convert: s32) -> void #foreign stb_image;
// flip the image vertically, so the first pixel in the output array is the bottom left
stbi_set_flip_vertically_on_load :: (flag_true_if_should_flip: s32) -> void #foreign stb_image;
// as above, but only applies to images loaded on the thread that calls the function
// this function is only available if your compiler supports thread-local variables;
// calling it will fail to link if your compiler doesn't
stbi_set_unpremultiply_on_load_thread :: (flag_true_if_should_unpremultiply: s32) -> void #foreign stb_image;
stbi_convert_iphone_png_to_rgb_thread :: (flag_true_if_should_convert: s32) -> void #foreign stb_image;
stbi_set_flip_vertically_on_load_thread :: (flag_true_if_should_flip: s32) -> void #foreign stb_image;
// ZLIB client - used by PNG, available for other purposes
stbi_zlib_decode_malloc_guesssize :: (buffer: *u8, len: s32, initial_size: s32, outlen: *s32) -> *u8 #foreign stb_image;
stbi_zlib_decode_malloc_guesssize_headerflag :: (buffer: *u8, len: s32, initial_size: s32, outlen: *s32, parse_header: s32) -> *u8 #foreign stb_image;
stbi_zlib_decode_malloc :: (buffer: *u8, len: s32, outlen: *s32) -> *u8 #foreign stb_image;
stbi_zlib_decode_buffer :: (obuffer: *u8, olen: s32, ibuffer: *u8, ilen: s32) -> s32 #foreign stb_image;
stbi_zlib_decode_noheader_malloc :: (buffer: *u8, len: s32, outlen: *s32) -> *u8 #foreign stb_image;
stbi_zlib_decode_noheader_buffer :: (obuffer: *u8, olen: s32, ibuffer: *u8, ilen: s32) -> s32 #foreign stb_image;
#scope_file
#if OS == .WINDOWS {
stb_image :: #library "windows/stb_image";
} else #if OS == .LINUX {
stb_image :: #library "linux/stb_image";
} else #if OS == .MACOS {
stb_image :: #library "macos/stb_image";
} else #if OS == .ANDROID {
#if CPU == .X64 {
stb_image :: #library "android/x64/stb_image";
} else #if CPU == .ARM64 {
stb_image :: #library "android/arm64/stb_image";
}
} else #if OS == .PS5 {
stb_image :: #library "ps5/stb_image";
} else #if OS == .WASM {
stb_image :: #library "wasm/stb_image";
} else {
#assert false;
}

View File

@ -0,0 +1,151 @@
AT_COMPILE_TIME :: true;
SOURCE_PATH :: "source";
LIB_BASE_NAME :: "stb_image";
#if AT_COMPILE_TIME {
#run,stallable {
set_build_options_dc(.{do_output=false});
options := get_build_options();
args := options.compile_time_command_line;
if !generate_bindings(args, options.minimum_os_version) {
compiler_set_workspace_status(.FAILED);
}
}
} else {
#import "System";
main :: () {
set_working_directory(path_strip_filename(get_path_of_running_executable()));
if !generate_bindings(get_command_line_arguments(), #run get_build_options().minimum_os_version) {
exit(1);
}
}
}
generate_bindings :: (args: [] string, minimum_os_version: type_of(Build_Options.minimum_os_version)) -> bool {
target_android := array_find(args, "-android");
target_x64 := array_find(args, "-x64");
target_arm := array_find(args, "-arm64");
compile := array_find(args, "-compile");
compile_debug := array_find(args, "-debug");
os_target := OS;
cpu_target := CPU;
if target_android os_target = .ANDROID;
if target_x64 cpu_target = .X64;
if target_arm cpu_target = .ARM64;
lib_directory: string;
if os_target == {
case .WINDOWS;
lib_directory = "windows";
case .LINUX;
lib_directory = "linux";
case .MACOS;
lib_directory = "macos";
case .ANDROID;
lib_directory = ifx cpu_target == .X64 then "android/x64" else "android/arm64";
case .PS5;
lib_directory = "ps5";
case;
assert(false);
}
if compile {
source_file := tprint("%/stb_image.c", SOURCE_PATH);
make_directory_if_it_does_not_exist(lib_directory, recursive = true);
lib_path := tprint("%/%", lib_directory, LIB_BASE_NAME);
success := true;
if os_target == .MACOS {
lib_path_x64 := tprint("%_x64", lib_path);
lib_path_arm64 := tprint("%_arm64", lib_path);
macos_x64_version_arg := "-mmacos-version-min=10.13"; // Our current x64 min version
macos_arm64_version_arg := "-mmacos-version-min=11.0"; // Earliest version that supports arm64
// x64 variant
success &&= build_cpp_dynamic_lib(lib_path_x64, source_file, extra = .["-arch", "x86_64", macos_x64_version_arg], debug=compile_debug);
success &&= build_cpp_static_lib( lib_path_x64, source_file, extra = .["-arch", "x86_64", macos_x64_version_arg], debug=compile_debug);
// arm64 variant
success &&= build_cpp_dynamic_lib(lib_path_arm64, source_file, extra = .["-arch", "arm64", macos_arm64_version_arg], debug=compile_debug);
success &&= build_cpp_static_lib( lib_path_arm64, source_file, extra = .["-arch", "arm64", macos_arm64_version_arg], debug=compile_debug);
// create universal binaries
run_result := run_command("lipo", "-create", tprint("%.dylib", lib_path_x64), tprint("%.dylib", lib_path_arm64), "-output", tprint("%.dylib", lib_path));
success &&= (run_result.exit_code == 0);
run_result = run_command("lipo", "-create", tprint("%.a", lib_path_x64), tprint("%.a", lib_path_arm64), "-output", tprint("%.a", lib_path));
success &&= (run_result.exit_code == 0);
} else {
extra: [..] string;
if os_target == .ANDROID {
_, target_triple_with_sdk := get_android_target_triple(cpu_target);
array_add(*extra, "-target", target_triple_with_sdk);
}
if os_target != .WINDOWS {
array_add(*extra, "-fPIC");
}
if os_target != .PS5 && os_target != .WASM {
success &&= build_cpp_dynamic_lib(lib_path, source_file, target = os_target, debug = compile_debug, extra = extra);
}
success &&= build_cpp_static_lib(lib_path, source_file, target = os_target, debug = compile_debug, extra = extra);
}
if !success return false;
}
options: Generate_Bindings_Options;
options.os = os_target;
options.cpu = cpu_target;
{
using options;
array_add(*libpaths, lib_directory);
array_add(*libnames, LIB_BASE_NAME);
array_add(*source_files, tprint("%/stb_image.h", SOURCE_PATH));
array_add(*typedef_prefixes_to_unwrap, "stbi_");
generate_library_declarations = false;
footer = tprint(FOOTER_TEMPLATE, LIB_BASE_NAME);
auto_detect_enum_prefixes = true;
log_stripped_declarations = false;
generate_compile_time_struct_checks = false;
}
output_filename := "bindings.jai";
return generate_bindings(options, output_filename);
}
FOOTER_TEMPLATE :: #string END
#if OS == .WINDOWS {
%1 :: #library "windows/%1";
} else #if OS == .LINUX {
%1 :: #library "linux/%1";
} else #if OS == .MACOS {
%1 :: #library "macos/%1";
} else #if OS == .ANDROID {
#if CPU == .X64 {
%1 :: #library "android/x64/%1";
} else #if CPU == .ARM64 {
%1 :: #library "android/arm64/%1";
}
} else #if OS == .PS5 {
%1 :: #library "ps5/%1";
} else #if OS == .WASM {
// Wasm will be linked with emcc.
} else {
#assert false;
}
END
#import "Basic";
#import "Bindings_Generator";
#import "BuildCpp";
#import "Compiler";
#import "File";
#import "Process";
#import "Toolchains/Android";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,12 @@
#load "bindings.jai";
#if OS == .WINDOWS || OS == .PS5 || OS == .WASM {
#scope_module
FILE :: void;
} else #if OS_IS_UNIX {
#import "POSIX";
#library,system,link_always "libm";
} else {
#assert false;
}

View File

@ -0,0 +1,11 @@
#ifdef WIN32
#define __EXPORT __declspec(dllexport)
#else
#define __EXPORT
#endif
#define STBIDEF extern __EXPORT
#define STBI_NO_STDIO
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

109
src/audio/analysis.jai Normal file
View File

@ -0,0 +1,109 @@
//
// Real spectrum analysis driving the visualizer.
//
// Each frame we pull FFT_SIZE PCM frames around the active stream's
// play_cursor, mix to mono, window with Hann, FFT, then bin the magnitudes
// into SPECTRUM_BINS log-spaced bands. The result lives in `app.spectrum`,
// where `gfx/shaders.jai` reads it for the bar visualizer.
//
// Behaviour at edges:
// - no stream playing → bars decay smoothly to zero
// - paused (play_cursor frozen) → spectrum still decays so the screen
// visibly stops reacting
// - OGG / non-PCM data → we don't have direct sample access, so spectrum
// decays. (All our /universal-fetched tracks decode through dr_libs
// into LINEAR_SAMPLE_ARRAY anyway, so this rarely fires in practice.)
//
MIN_VISUAL_FREQ :: 30.0; // skip DC and very-low rumble bins
update_audio_analysis :: () {
fft_init();
if !app.current_stream || !app.current_stream.sound_data || app.paused {
decay_spectrum(0.10);
return;
}
sd := app.current_stream.sound_data;
if sd.type != Sound.Sound_Data.Kind.LINEAR_SAMPLE_ARRAY {
decay_spectrum(0.10);
return;
}
nchannels := cast(s64) sd.nchannels;
if nchannels < 1 {
decay_spectrum(0.10);
return;
}
total_frames := sd.nsamples_times_nchannels / nchannels;
cursor_frame := cast(s64) app.current_stream.play_cursor;
start := cursor_frame - FFT_SIZE / 2;
if start < 0 start = 0;
if start + FFT_SIZE > total_frames start = total_frames - FFT_SIZE;
if start < 0 {
// track shorter than the FFT window
decay_spectrum(0.10);
return;
}
// Mix down to mono and apply window.
samples := sd.samples;
inv_chan := 1.0 / cast(float) nchannels;
for k: 0..FFT_SIZE-1 {
idx := (start + k) * nchannels;
sum: float = 0;
for ch: 0..nchannels-1 sum += cast(float) samples[idx + ch];
mono := sum * inv_chan / 32768.0; // s16 → [-1, 1]
fft_re[k] = mono * fft_window[k];
fft_im[k] = 0;
}
fft();
// Bin into log-spaced bands.
rate := cast(float) sd.sampling_rate;
nyquist := rate * 0.5;
log_lo := log(MIN_VISUAL_FREQ);
log_hi := log(nyquist);
log_step := (log_hi - log_lo) / cast(float) SPECTRUM_BINS;
for b: 0..SPECTRUM_BINS-1 {
f_lo := exp(log_lo + log_step * cast(float) b);
f_hi := exp(log_lo + log_step * cast(float) (b + 1));
bin_lo := cast(int) (f_lo * FFT_SIZE / rate);
bin_hi := cast(int) (f_hi * FFT_SIZE / rate);
if bin_hi <= bin_lo bin_hi = bin_lo + 1;
if bin_hi > HALF_FFT bin_hi = HALF_FFT;
if bin_lo >= HALF_FFT continue;
peak: float = 0;
for k: bin_lo..bin_hi-1 {
mag := sqrt(fft_re[k] * fft_re[k] + fft_im[k] * fft_im[k]);
if mag > peak peak = mag;
}
// Normalize by FFT_SIZE/4 (a Hann-windowed unit sine peaks around
// FFT_SIZE/4 in this convention) and compress with sqrt to match
// perceptual loudness better.
v := peak / (cast(float) FFT_SIZE * 0.25);
v = sqrt(v);
if v > 1 v = 1;
// Asymmetric smoothing: fast attack, slow decay (classic
// VU-meter feel).
prev := app.spectrum[b];
rate_lerp := ifx v > prev then 0.45 else 0.10;
app.spectrum[b] = lerp(prev, v, rate_lerp);
}
}
#scope_file
decay_spectrum :: (rate: float) {
for 0..SPECTRUM_BINS-1 {
app.spectrum[it] = lerp(app.spectrum[it], 0, rate);
}
}

110
src/audio/decoders.jai Normal file
View File

@ -0,0 +1,110 @@
//
// Format-aware decoder. Detects MP3 / FLAC / OGG / WAV from the leading bytes
// and routes through dr_mp3/dr_flac (vendored, modules/audio_decoders) for the
// formats Sound_Player doesn't natively handle.
//
// Native formats (OGG, WAV) go through Sound.load_audio_data unchanged.
// MP3/FLAC are decoded to interleaved s16 PCM and wrapped in a Sound_Data
// with type = .LINEAR_SAMPLE_ARRAY.
//
Audio_Format :: enum {
UNKNOWN;
OGG;
WAV;
MP3;
FLAC;
}
detect_audio_format :: (bytes: string) -> Audio_Format {
if bytes.count < 4 return .UNKNOWN;
b := bytes.data;
// MP3: ID3 tag
if b[0] == #char "I" && b[1] == #char "D" && b[2] == #char "3" return .MP3;
// MP3: frame sync 0xFFEx (also 0xFFFx for MPEG-1, 0xFFEx for MPEG-2)
if b[0] == 0xFF && (b[1] & 0xE0) == 0xE0 return .MP3;
// FLAC: "fLaC"
if b[0] == #char "f" && b[1] == #char "L" && b[2] == #char "a" && b[3] == #char "C" return .FLAC;
// OGG: "OggS"
if b[0] == #char "O" && b[1] == #char "g" && b[2] == #char "g" && b[3] == #char "S" return .OGG;
// WAV: "RIFF"
if b[0] == #char "R" && b[1] == #char "I" && b[2] == #char "F" && b[3] == #char "F" return .WAV;
return .UNKNOWN;
}
//
// Decode `bytes` into a Sound_Data ready to feed to Sound.make_stream().
//
// Ownership notes:
// - On the OGG/WAV path, Sound_Player keeps `bytes` alive via Sound_Data.buffer.
// - On the MP3/FLAC path, we call decoder_free on the original bytes immediately
// (we have decoded to a separate s16 PCM buffer); the Sound_Data references
// the dr_libs-malloc'd PCM buffer instead.
//
// TODO: free Sound_Data + its buffer in the release_asset path. Today we leak.
//
decode_audio :: (bytes: string, name: string) -> Sound.Sound_Data, Audio_Format, bool {
format := detect_audio_format(bytes);
log_info("decode_audio: '%' bytes=% format=%", name, bytes.count, format);
if format == .OGG || format == .WAV {
result := Sound.load_audio_data(name, bytes);
return result, format, result.loaded;
}
if format == .MP3 || format == .FLAC {
channels: u32;
sample_rate: u32;
total_frames: u64;
samples: *s16;
if format == .MP3 {
samples = Audio_Decoders.decode_mp3(
bytes.data, cast(u64) bytes.count,
*channels, *sample_rate, *total_frames);
} else {
samples = Audio_Decoders.decode_flac(
bytes.data, cast(u64) bytes.count,
*channels, *sample_rate, *total_frames);
}
if !samples {
log_error("decode_audio: decoder failed for '%'", name);
return .{}, format, false;
}
// Source bytes are no longer needed.
free(bytes);
total_samples := total_frames * cast(u64) channels;
result: Sound.Sound_Data;
result.name = copy_string(name);
result.loaded = true;
result.type = .LINEAR_SAMPLE_ARRAY;
result.nchannels = cast(u16) channels;
result.sampling_rate = sample_rate;
result.nsamples_times_nchannels = cast(s64) total_samples;
result.samples = samples;
result.buffer.count = cast(s64) (total_samples * size_of(s16));
result.buffer.data = cast(*u8) samples;
log_info("decode_audio: % ch=%, rate=%, frames=%", format, channels, sample_rate, total_frames);
return result, format, true;
}
if bytes.count >= 8 && bytes.data {
b := bytes.data;
log_error("decode_audio: unknown format for '%' (% bytes; first 8: %x %x %x %x %x %x %x %x)",
name, bytes.count,
formatInt(b[0], base=16), formatInt(b[1], base=16),
formatInt(b[2], base=16), formatInt(b[3], base=16),
formatInt(b[4], base=16), formatInt(b[5], base=16),
formatInt(b[6], base=16), formatInt(b[7], base=16));
} else {
log_error("decode_audio: unknown format for '%' (% bytes — too short or null)", name, bytes.count);
}
return .{}, format, false;
}

82
src/audio/fft.jai Normal file
View File

@ -0,0 +1,82 @@
//
// Tiny in-place radix-2 Cooley-Tukey FFT. Module-level scratch buffers so we
// don't allocate per frame.
//
// FFT_SIZE is a compile-time constant (power of two). 1024 at 44.1 kHz gives
// ~23 ms windows and ~43 Hz bin resolution — a good balance for the
// spectrum visualizer.
//
FFT_SIZE :: 1024;
HALF_FFT :: FFT_SIZE / 2;
fft_window: [FFT_SIZE] float;
fft_re: [FFT_SIZE] float;
fft_im: [FFT_SIZE] float;
fft_initted: bool;
fft_init :: () {
if fft_initted return;
// Hann window: smooth attenuation at edges, no spectral leakage from
// arbitrary-phase sample boundaries.
for 0..FFT_SIZE-1 {
t := cast(float) it / cast(float) (FFT_SIZE - 1);
fft_window[it] = 0.5 - 0.5 * cos(2 * PI * t);
}
fft_initted = true;
}
//
// In-place complex FFT on (fft_re, fft_im).
//
fft :: () {
n := FFT_SIZE;
// Bit-reverse permutation.
j := 0;
for i: 0..n-2 {
if i < j {
t_re := fft_re[i]; t_im := fft_im[i];
fft_re[i] = fft_re[j]; fft_im[i] = fft_im[j];
fft_re[j] = t_re; fft_im[j] = t_im;
}
bit := n >> 1;
while j & bit {
j ^= bit;
bit >>= 1;
}
j ^= bit;
}
// Cooley-Tukey butterflies.
seg_len := 2;
while seg_len <= n {
ang := -2.0 * PI / cast(float) seg_len;
w_re := cos(ang);
w_im := sin(ang);
i := 0;
while i < n {
tw_re := 1.0;
tw_im := 0.0;
half := seg_len / 2;
for k: 0..half-1 {
a := i + k;
b := i + k + half;
v_re := fft_re[b] * tw_re - fft_im[b] * tw_im;
v_im := fft_re[b] * tw_im + fft_im[b] * tw_re;
fft_re[b] = fft_re[a] - v_re;
fft_im[b] = fft_im[a] - v_im;
fft_re[a] = fft_re[a] + v_re;
fft_im[a] = fft_im[a] + v_im;
next_re := tw_re * w_re - tw_im * w_im;
next_im := tw_re * w_im + tw_im * w_re;
tw_re = next_re;
tw_im = next_im;
}
i += seg_len;
}
seg_len *= 2;
}
}

5
src/audio/index.jai Normal file
View File

@ -0,0 +1,5 @@
#load "decoders.jai";
#load "player.jai";
#load "queue.jai";
#load "fft.jai";
#load "analysis.jai";

129
src/audio/player.jai Normal file
View File

@ -0,0 +1,129 @@
//
// Audio playback. The track download is async — `audio_play_track` returns
// immediately after queueing the request; when the bytes arrive we decode
// them and start a Sound_Player stream.
//
// Pause is REAL: setting `current_rate = desired_rate = 0` freezes the
// stream's play_cursor and (with `inaudible = true`) silences output. So
// while paused the seek bar doesn't drift and auto-advance can't fire.
//
audio_play_track :: (track: Track) {
if !app.audio_inited return;
pending := New(Track);
pending.* = clone_track(track);
// /universal lets the server pick the best format, transcoding to mp3
// when the original isn't in our `container` list. Without this, Opus /
// M4A / AAC / WMA / ALAC files come back as bytes we can't decode.
path := tprint(
"/Audio/%/universal?container=mp3,flac,ogg,wav&audioCodec=mp3&maxStreamingBitrate=320000&userId=%&deviceId=%&api_key=%",
track.id, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token,
);
log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks);
http_submit("GET", path, on_done=on_track_downloaded, user_data=pending);
}
audio_toggle_pause :: () {
if !app.current_stream return;
s := app.current_stream;
app.paused = !app.paused;
if app.paused {
s.current_rate = 0;
s.desired_rate = 0;
s.inaudible = true;
} else {
s.current_rate = 1;
s.desired_rate = 1;
s.inaudible = false;
}
}
audio_is_paused :: () -> bool {
return app.paused;
}
audio_seek_seconds :: (seconds: float) {
if !app.current_stream || !app.current_stream.sound_data return;
rate := cast(float64) app.current_stream.sound_data.sampling_rate;
target := cast(float64) seconds * rate;
if target < 0 target = 0;
app.current_stream.play_cursor = target;
}
stop_current_stream :: () {
if !app.current_stream return;
Sound.stop_stream_abruptly(app.current_stream.entity_id);
app.current_stream = null;
}
sound_release_callback :: (stream: *Sound.Sound_Stream, data: *Sound.Sound_Data) {
if stream && stream.entity_id == app.track_entity_id {
app.track_finished = true;
app.current_stream = null;
}
}
clone_track :: (t: Track) -> Track {
out: Track;
out.id = copy_string(t.id);
out.name = copy_string(t.name);
out.album = copy_string(t.album);
out.album_id = copy_string(t.album_id);
out.artist = copy_string(t.artist);
out.duration_ticks = t.duration_ticks;
out.index_number = t.index_number;
return out;
}
free_track :: (t: *Track) {
free(t.id);
free(t.name);
free(t.album);
free(t.album_id);
free(t.artist);
t.* = .{};
}
#scope_file
on_track_downloaded :: (task: *Http_Task) {
pending := cast(*Track) task.user_data;
defer { free_track(pending); free(pending); }
if !task.response.ok {
log_error("audio download '%' failed: status=%", pending.name, task.response.status_code);
return;
}
bytes := task.response.body;
task.response.body = "";
sd, format, ok := decode_audio(bytes, pending.name);
if !ok {
log_error("audio decode failed for '%'", pending.name);
free(bytes);
return;
}
data := New(Sound.Sound_Data);
data.* = sd;
stop_current_stream();
free_track(*app.current_track);
app.current_track = clone_track(pending.*);
app.current_format = format;
// New track always starts un-paused; user clicking Next/Prev or
// auto-advance both want fresh playback state.
app.paused = false;
app.track_entity_id += 1;
stream := Sound.make_stream(data, .MUSIC);
stream.entity_id = app.track_entity_id;
Sound.start_playing(stream);
app.current_stream = stream;
app.track_finished = false;
log_info("audio: playing '% — %' [%]", app.current_track.artist, app.current_track.name, format);
}

36
src/audio/queue.jai Normal file
View File

@ -0,0 +1,36 @@
//
// Naive play queue. Linear array, current index. Shuffle/repeat punted.
// audio_play_track is now async (returns void); the queue just fires the
// download and trusts it to land.
//
Queue :: struct {
tracks: [..] Track;
current: int = -1;
}
queue: Queue;
queue_clear :: () {
array_reset(*queue.tracks);
queue.current = -1;
}
queue_add :: (track: Track) {
array_add(*queue.tracks, track);
}
queue_play_index :: (index: int) -> bool {
if index < 0 || index >= queue.tracks.count return false;
queue.current = index;
audio_play_track(queue.tracks[index]);
return true;
}
queue_next :: () -> bool {
return queue_play_index(queue.current + 1);
}
queue_prev :: () -> bool {
return queue_play_index(queue.current - 1);
}

99
src/core/app.jai Normal file
View File

@ -0,0 +1,99 @@
//
// Global app state. Everything that lives across frames hangs off `app`.
//
View :: enum {
LOGIN;
LIBRARY;
NOW_PLAYING;
}
App :: struct {
// Window
window: Window_Type;
window_width: s32 = 1280;
window_height: s32 = 800;
quit: bool;
// Time
current_time: float64;
last_time: float64;
dt: float;
// UI
current_view: View = .LOGIN;
current_theme: s32; // index into default_theme_procs
theme: Overall_Theme;
title_font: *Simp.Dynamic_Font;
big_font: *Simp.Dynamic_Font;
row_font: *Simp.Dynamic_Font; // sized for the artist/album/track rows
button_font: *Simp.Dynamic_Font;
body_font: *Simp.Dynamic_Font;
// Jellyfin
jellyfin: Jellyfin_Client;
// Audio
audio_inited: bool;
current_stream: *Sound.Sound_Stream;
current_track: Track;
current_format: Audio_Format; // format of the bytes we played
track_finished: bool; // set by Sound_Player release_asset callback
track_entity_id: s64; // monotonic — identifies the active stream
paused: bool; // real pause: stream's current_rate is 0 while true
master_volume: float = 1.0; // 0..1; persisted in config.json
scrub_seconds: float; // bound to the seek slider in now_playing_view
// Per-frame audio analysis used by the visualizer shader.
spectrum: [SPECTRUM_BINS] float;
// Library browse state.
library: Library_State;
}
SPECTRUM_BINS :: 64;
app: App;
app_init :: () {
log_info("player starting up");
setup_data_directory();
app.window = create_window(app.window_width, app.window_height, "player");
Simp.set_render_target(app.window, .LEFT_HANDED);
init_fonts();
ui_init();
sound_cfg: Sound.Sound_Player_Config;
sound_cfg.release_asset = sound_release_callback;
if Sound.sound_player_init(sound_cfg) {
app.audio_inited = true;
} else {
log_warn("sound player init failed — playback disabled");
}
jellyfin_client_init(*app.jellyfin);
gfx_init();
image_cache_init();
// Sensible defaults; config_load may overwrite.
app.jellyfin.server_url = copy_string("http://localhost:8096");
app.jellyfin.username = copy_string("");
app.jellyfin.password = copy_string("");
if config_load() {
app.current_view = .LIBRARY;
library_refresh_artists();
}
}
app_shutdown :: () {
if app.audio_inited Sound.sound_player_shutdown();
jellyfin_client_shutdown(*app.jellyfin);
log_info("bye");
}

87
src/core/config.jai Normal file
View File

@ -0,0 +1,87 @@
//
// Persisted state. Stored at ~/.config/player/config.json on Linux,
// ~/Library/Application Support/player/config.json on macOS.
//
// We persist:
// - server URL / username / auth token / user id (so subsequent launches
// skip the login screen entirely)
// - master volume (so the user doesn't have to re-set it every time)
//
// Password is never persisted.
//
Persisted_Config :: struct {
server_url: string;
username: string;
auth_token: string;
user_id: string;
master_volume: float = 1.0;
}
config_path :: () -> string {
home, ok := get_home_directory();
if !ok return "";
#if OS == .MACOS {
return tprint("%/Library/Application Support/player/config.json", home);
} else {
return tprint("%/.config/player/config.json", home);
}
}
config_save :: () {
p := config_path();
if !p return;
cfg: Persisted_Config;
cfg.server_url = app.jellyfin.server_url;
cfg.username = app.jellyfin.username;
cfg.auth_token = app.jellyfin.auth_token;
cfg.user_id = app.jellyfin.user_id;
cfg.master_volume = app.master_volume;
json := Jaison.json_write_string(cfg);
defer free(json);
dir := path_strip_filename(p);
make_directory_if_it_does_not_exist(dir, recursive=true);
if !write_entire_file(p, json) {
log_error("config_save: failed to write %", p);
return;
}
}
config_load :: () -> bool {
p := config_path();
if !p return false;
if !file_exists(p) return false;
contents, ok := read_entire_file(p, log_errors=false);
if !ok return false;
defer free(contents);
parse_ok, cfg := Jaison.json_parse_string(contents, Persisted_Config);
if !parse_ok {
log_warn("config_load: failed to parse %", p);
return false;
}
app.jellyfin.server_url = copy_string(cfg.server_url);
app.jellyfin.username = copy_string(cfg.username);
app.jellyfin.auth_token = copy_string(cfg.auth_token);
app.jellyfin.user_id = copy_string(cfg.user_id);
if cfg.master_volume > 0 app.master_volume = clamp(cfg.master_volume, 0, 1);
if cfg.auth_token.count > 0 && cfg.user_id.count > 0 {
app.jellyfin.logged_in = true;
log_info("loaded saved login (user_id=%)", cfg.user_id);
return true;
}
return false;
}
config_clear :: () {
p := config_path();
if !p return;
file_delete(p);
}

24
src/core/imports.jai Normal file
View File

@ -0,0 +1,24 @@
//
// All module imports for the project. Pulled into a dedicated file so that
// every folder doesn't redeclare them.
//
#import "Basic";
#import "Math";
#import "String";
#import "System";
#import "File";
#import "File_Utilities";
#import "Random";
#import "Hash_Table";
#import "Window_Creation";
#import "GetRect_LeftHanded";
Simp :: #import "Simp";
Input :: #import "Input";
Sound :: #import "Sound_Player";
Jaison :: #import "Jaison";
Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true);
Audio_Decoders :: #import "audio_decoders";

6
src/core/index.jai Normal file
View File

@ -0,0 +1,6 @@
// imports.jai is #loaded directly from main.jai before everything else.
#load "app.jai";
#load "window.jai";
#load "time.jai";
#load "config.jai";

7
src/core/time.jai Normal file
View File

@ -0,0 +1,7 @@
tick_time :: () {
app.current_time = seconds_since_init();
delta := cast(float)(app.current_time - app.last_time);
Clamp(*delta, 0, 0.1);
app.dt = delta;
app.last_time = app.current_time;
}

71
src/core/window.jai Normal file
View File

@ -0,0 +1,71 @@
//
// Main loop. Pulls input, handles resizes, dispatches to the current view,
// swaps buffers.
//
run_main_loop :: () {
while !app.quit {
tick_time();
Input.update_window_events();
for Input.get_window_resizes() {
Simp.update_window(it.window);
if it.window == app.window {
resized := (it.width != app.window_width) || (it.height != app.window_height);
app.window_width = it.width;
app.window_height = it.height;
if resized init_fonts();
}
}
for event: Input.events_this_frame {
if event.type == .QUIT then app.quit = true;
getrect_handle_event(event);
}
http_pump(); // fire callbacks for any HTTP requests that finished since last frame
image_pump(); // hand queued image fetches to http_submit, up to the concurrency cap
if app.audio_inited Sound.set_master_volume(app.master_volume);
draw_one_frame();
if app.audio_inited Sound.update();
// Auto-advance once the active stream has finished.
if app.track_finished {
app.track_finished = false;
queue_next();
}
reset_temporary_storage();
sleep_milliseconds(8);
}
}
draw_one_frame :: () {
proc := default_theme_procs[app.current_theme];
app.theme = proc();
set_default_theme(app.theme);
bg := app.theme.background_color;
Simp.clear_render_target(bg.x, bg.y, bg.z, 1);
x, y, w, h := get_dimensions(app.window, true);
ui_per_frame_update(app.window, w, h, app.current_time);
// The visualizer shader runs *behind* the UI for every view that wants it.
if app.current_view != .LOGIN {
gfx_draw_visualizer_background(xx w, xx h);
}
if #complete app.current_view == {
case .LOGIN; draw_login_view();
case .LIBRARY; draw_library_view();
case .NOW_PLAYING; draw_now_playing_view();
}
draw_popups();
Simp.swap_buffers(app.window);
}

240
src/gfx/images.jai Normal file
View File

@ -0,0 +1,240 @@
//
// Async image cache with on-disk persistence.
//
// UI calls `image_request(item_id, size)` from the draw loop; it always
// returns an *Image immediately whose .loaded flips true asynchronously.
//
// Lookup order:
// 1. In-memory cache (this session) — instant
// 2. ~/.cache/player/images/<size>_<id>.bin — read on the main thread,
// rate-limited by image_pump
// 3. Jellyfin /Items/{id}/Images/Primary — async http_submit, capped
//
// On a fresh first run the user gets the slow path once; from that point on
// every relaunch hits disk and the artist/album walls render instantly.
//
// Concurrency:
// - HTTP fetches are capped (MAX_CONCURRENT_IMAGE_FETCHES) to keep the
// thread count sane on a 500-album library.
// - Disk hits are capped per frame (PROCESS_PER_FRAME) so first-frame
// hydration of hundreds of thumbs spreads across ~10 frames instead of
// blocking the UI for 400 ms.
//
// Decode + texture upload runs in the same place that consumed the bytes:
// - Disk hit → image_pump (main thread)
// - HTTP hit → on_image_response (already main-thread per jellyfin/async.jai)
// OpenGL stays single-threaded.
//
Image :: struct {
texture: Simp.Texture;
loaded: bool;
loading: bool;
failed: bool;
}
Image_Size :: enum {
THUMB; // 128 px square — for list rows
LARGE; // 512 px square — for now-playing
BACKDROP; // 1920x1080 — full-screen artist backdrop
}
#scope_module
Image_Request :: struct {
image: *Image;
id: string;
size: Image_Size;
}
image_cache: Table(string, *Image); // key = "<size>:<itemid>"
image_pending: [..] *Image_Request;
image_in_flight: int;
MAX_CONCURRENT_IMAGE_FETCHES :: 4;
PROCESS_PER_FRAME :: 16;
#scope_export
//
// Idempotent — safe to call from app_init or first image_request, called once
// from app_init for clarity.
//
image_cache_init :: () {
dir := image_cache_dir();
if !dir return;
make_directory_if_it_does_not_exist(dir, recursive=true);
}
image_request :: (item_id: string, size: Image_Size) -> *Image {
key := image_cache_key(item_id, size);
found, existing := table_find(*image_cache, key);
if found {
free(key);
return existing;
}
img := New(Image);
img.loading = true;
table_set(*image_cache, key, img);
req := New(Image_Request);
req.image = img;
req.id = copy_string(item_id);
req.size = size;
array_add(*image_pending, req);
return img;
}
//
// Once per frame, before draw. Drains the pending queue: synchronous disk
// reads where we have a cached file, http_submit for the rest. Both paths are
// rate-limited so a 400-thumb cold-start doesn't stall the frame.
//
image_pump :: () {
processed := 0;
i := 0;
while processed < PROCESS_PER_FRAME && i < image_pending.count {
req := image_pending[i];
disk_path := image_disk_path(req.id, req.size);
if file_exists(disk_path) {
consume_disk_hit(req, disk_path);
free(req.id);
free(req);
array_ordered_remove_by_index(*image_pending, i);
processed += 1;
// Do NOT increment i — the array shifted.
} else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES {
url := build_image_url(req.id, req.size);
http_submit("GET", url, on_done=on_image_response, user_data=req);
image_in_flight += 1;
array_ordered_remove_by_index(*image_pending, i);
processed += 1;
} else {
// Out of HTTP slots, disk miss — leave for next frame.
i += 1;
}
}
}
draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
if img && img.loaded {
Simp.set_shader_for_images(*img.texture);
Simp.immediate_quad(
Vector2.{x, y },
Vector2.{x + w, y },
Vector2.{x + w, y + h},
Vector2.{x, y + h},
color = tint,
);
} else {
Simp.set_shader_for_color();
col: Vector4;
if img && img.failed col = .{0.18, 0.04, 0.10, 1};
else col = .{0.10, 0.05, 0.18, 1};
Simp.immediate_quad(x, y, x + w, y + h, col);
}
}
#scope_file
build_image_url :: (item_id: string, size: Image_Size) -> string {
if size == .BACKDROP {
// Backdrops are 16:9, fetch at 1080p quality
return tprint(
"/Items/%/Images/Backdrop/0?fillWidth=1920&fillHeight=1080&quality=85",
item_id,
);
} else {
side := ifx size == .THUMB then 128 else 512;
return tprint(
"/Items/%/Images/Primary?fillHeight=%&fillWidth=%&quality=80",
item_id, side, side,
);
}
}
image_cache_key :: (item_id: string, size: Image_Size) -> string {
prefix: string;
if size == {
case .THUMB; prefix = "t:";
case .LARGE; prefix = "l:";
case .BACKDROP; prefix = "b:";
}
b: String_Builder;
append(*b, prefix);
append(*b, item_id);
return builder_to_string(*b);
}
image_cache_dir :: () -> string {
home, ok := get_home_directory();
if !ok return "";
#if OS == .MACOS {
return tprint("%/Library/Caches/player/images", home);
} else {
return tprint("%/.cache/player/images", home);
}
}
image_disk_path :: (item_id: string, size: Image_Size) -> string {
prefix: string;
if size == {
case .THUMB; prefix = "t";
case .LARGE; prefix = "l";
case .BACKDROP; prefix = "b";
}
// Jellyfin item ids are 32-char hex strings — safe as filenames as-is.
return tprint("%/%_%.bin", image_cache_dir(), prefix, item_id);
}
consume_disk_hit :: (req: *Image_Request, disk_path: string) {
img := req.image;
img.loading = false;
bytes, ok := read_entire_file(disk_path, log_errors=false);
if !ok {
img.failed = true;
return;
}
defer free(bytes);
buf: [] u8 = ---;
buf.data = bytes.data;
buf.count = bytes.count;
if !Simp.texture_load_from_memory(*img.texture, buf) {
img.failed = true;
return;
}
img.loaded = true;
}
on_image_response :: (task: *Http_Task) {
image_in_flight -= 1;
req := cast(*Image_Request) task.user_data;
defer { free(req.id); free(req); }
img := req.image;
img.loading = false;
if !task.response.ok || task.response.body.count == 0 {
img.failed = true;
return;
}
// Write to disk first so even if the texture upload fails we keep the
// bytes for next time. write_entire_file failures (out of space, perm)
// are silent — display is still possible.
disk_path := image_disk_path(req.id, req.size);
write_entire_file(disk_path, task.response.body);
body_bytes: [] u8 = ---;
body_bytes.data = task.response.body.data;
body_bytes.count = task.response.body.count;
if !Simp.texture_load_from_memory(*img.texture, body_bytes) {
img.failed = true;
return;
}
img.loaded = true;
}

2
src/gfx/index.jai Normal file
View File

@ -0,0 +1,2 @@
#load "shaders.jai";
#load "images.jai";

90
src/gfx/shaders.jai Normal file
View File

@ -0,0 +1,90 @@
//
// Visualizer backdrop. Reads `app.spectrum` (filled by audio/analysis.jai with
// a real FFT each frame) and draws a mirrored bar visualizer with a
// bass-driven background pulse.
//
// All immediate-mode quads via Simp for now. A real GLSL fragment shader is
// on the roadmap (ai/todo.md) — once that lands this becomes a fullscreen
// quad reading the spectrum + waveform as 1D textures.
//
gfx_init :: () {
// Nothing yet.
}
gfx_draw_visualizer_background :: (w: float, h: float) {
update_audio_analysis();
Simp.set_shader_for_color();
t := cast(float) app.current_time;
// Bass = average of the lowest ~16% of bins. Drives a subtle base
// brightness pulse so the whole screen breathes with the kick drum.
bass: float = 0;
bass_bins := SPECTRUM_BINS / 6;
if bass_bins < 1 bass_bins = 1;
for 0..bass_bins-1 bass += app.spectrum[it];
bass /= cast(float) bass_bins;
base := Vector4.{
0.04 + 0.10 * bass,
0.02 + 0.04 * bass,
0.10 + 0.18 * bass,
1,
};
Simp.immediate_quad(0, 0, w, h, base);
// Mirrored bars from the horizontal mid-line — bass on the left, treble
// on the right, both spreading top and bottom.
cy := h * 0.5;
bar_w := w / cast(float) SPECTRUM_BINS;
max_bar := h * 0.45;
for i: 0..SPECTRUM_BINS-1 {
v := app.spectrum[i];
bar_h := v * max_bar;
x0 := cast(float) i * bar_w;
x1 := x0 + bar_w * 0.85;
hue := cast(float) i / cast(float) SPECTRUM_BINS;
body := neon_color(hue, v, t);
tip := body;
tip.x = min(1.0, tip.x + 0.4);
tip.y = min(1.0, tip.y + 0.4);
tip.z = min(1.0, tip.z + 0.4);
// Top half (going up).
Simp.immediate_quad(x0, cy - bar_h, x1, cy, body);
// Bottom half (going down) — slightly shorter so the eye reads the
// top half as primary.
Simp.immediate_quad(x0, cy, x1, cy + bar_h * 0.85, body);
// Bright tip cap on the top bar (the classic Winamp peak nub).
cap_h := max_bar * 0.012;
if bar_h > cap_h * 2 {
Simp.immediate_quad(x0, cy - bar_h - cap_h, x1, cy - bar_h, tip);
}
}
// Center mirror line — a thin neon glow across the middle for the
// 2000s-skin vibe.
{
line_h := 1.5;
line_col := Vector4.{1.0, 0.4, 0.8, 0.5 + 0.3 * bass};
Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col);
}
}
#scope_file
neon_color :: (hue: float, intensity: float, t: float) -> Vector4 {
// Slow hue drift over time so the color palette evolves even on
// sustained tones.
h := hue + t * 0.03;
r := 0.5 + 0.5 * sin(h * 6.28318 + 0.0);
g := 0.5 + 0.5 * sin(h * 6.28318 + 2.094);
b := 0.5 + 0.5 * sin(h * 6.28318 + 4.188);
a := 0.55 + 0.45 * intensity;
boost := 0.45 + 0.55 * intensity;
return Vector4.{r * boost, g * boost, b * boost, a};
}

165
src/jellyfin/async.jai Normal file
View File

@ -0,0 +1,165 @@
//
// Async HTTP. Each request spawns a worker thread that runs libcurl's blocking
// easy-perform; the main loop polls `thread_is_done` each frame via
// `http_pump()` and fires the request's `on_done` callback on the main thread
// when the response is ready.
//
// All UI state mutation happens in the on_done callback, so handlers don't
// need to think about threading at all — just JSON-parse and write to
// `app.library` / `app.current_track` / etc.
//
// Per-request threads (vs a shared queue) keep this small. Library browsing
// and track downloads rarely have more than ~3 in flight; if we start
// flooding we'll graduate to a thread pool.
//
#import "Thread";
Http_Task :: struct {
method: string;
url: string;
body: string;
auth: string;
response: Http_Response;
// Bookkeeping. The worker writes `done` after `response` is fully
// populated; the main thread reads via thread_is_done.
done: bool;
thread: Thread;
user_data: *void;
on_done: #type (task: *Http_Task);
}
http_in_flight: [..] *Http_Task;
http_submit :: (
method: string,
path: string,
body: string = "",
on_done: #type (task: *Http_Task) = null,
user_data: *void = null,
) -> *Http_Task {
task := New(Http_Task);
task.method = copy_string(method);
task.url = copy_string(tprint("%/%", app.jellyfin.server_url, trim_left(path, "/")));
task.body = copy_string(body);
task.auth = copy_string(build_auth_header(*app.jellyfin));
task.on_done = on_done;
task.user_data = user_data;
if !thread_init(*task.thread, http_thread_proc) {
log_error("http_submit: thread_init failed");
free_task(task);
return null;
}
task.thread.data = task;
thread_start(*task.thread);
array_add(*http_in_flight, task);
return task;
}
//
// Called every frame from the main loop. Drains finished tasks.
//
http_pump :: () {
i := 0;
while i < http_in_flight.count {
task := http_in_flight[i];
if thread_is_done(*task.thread, 0) {
if task.on_done task.on_done(task);
array_unordered_remove_by_index(*http_in_flight, i);
free_task(task);
} else {
i += 1;
}
}
}
#scope_file
http_thread_proc :: (thread: *Thread) -> s64 {
task := cast(*Http_Task) thread.data;
task.response = perform_curl_blocking(task);
// Memory-ordering note: thread_is_done synchronizes with the worker's
// exit, so by the time the main thread sees the thread as finished, the
// response store is visible too. No explicit barrier needed.
task.done = true;
return 0;
}
free_task :: (task: *Http_Task) {
free(task.method);
free(task.url);
free(task.body);
free(task.auth);
free(task.response.body);
thread_deinit(*task.thread);
free(task);
}
Write_Buffer :: struct {
builder: String_Builder;
}
write_callback :: (contents: *u8, count: u64, size: u64, user: *Write_Buffer) -> u64 #c_call {
total := count * size;
push_context {
append(*user.builder, contents, cast(s64) total);
}
return total;
}
perform_curl_blocking :: (task: *Http_Task) -> Http_Response {
using Curl;
response: Http_Response;
handle := curl_easy_init();
if !handle {
log_error("http: curl_easy_init failed");
return response;
}
defer curl_easy_cleanup(handle);
curl_easy_setopt(handle, CURLoption.URL, temp_c_string(task.url));
// Jellyfin's /Audio/{id}/universal endpoint redirects to the actual
// transcoded stream. Without FOLLOWLOCATION we just get the 302 body
// (empty) and a 200/302 status, which downstream silently looks like
// "successful 0-byte download".
curl_easy_setopt(handle, CURLoption.FOLLOWLOCATION, cast(s64) 1);
curl_easy_setopt(handle, CURLoption.MAXREDIRS, cast(s64) 5);
buf: Write_Buffer;
curl_easy_setopt(handle, CURLoption.WRITEFUNCTION, write_callback);
curl_easy_setopt(handle, CURLoption.WRITEDATA, *buf);
headers: *curl_slist;
headers = curl_slist_append(headers, temp_c_string(tprint("X-Emby-Authorization: %", task.auth)));
headers = curl_slist_append(headers, temp_c_string("Accept: application/json"));
if task.method == "POST" || task.method == "PUT" {
headers = curl_slist_append(headers, temp_c_string("Content-Type: application/json"));
curl_easy_setopt(handle, CURLoption.POSTFIELDS, temp_c_string(task.body));
curl_easy_setopt(handle, CURLoption.POSTFIELDSIZE, cast(s64) task.body.count);
} else if task.method != "GET" {
curl_easy_setopt(handle, CURLoption.CUSTOMREQUEST, temp_c_string(task.method));
}
curl_easy_setopt(handle, CURLoption.HTTPHEADER, headers);
defer curl_slist_free_all(headers);
err := curl_easy_perform(handle);
if err != .OK {
msg := to_string(curl_easy_strerror(err));
log_error("curl % %: %", task.method, task.url, msg);
return response;
}
code: s64;
curl_easy_getinfo(handle, CURLINFO.RESPONSE_CODE, *code);
response.status_code = code;
response.body = builder_to_string(*buf.builder);
response.ok = code >= 200 && code < 300;
return response;
}

57
src/jellyfin/auth.jai Normal file
View File

@ -0,0 +1,57 @@
//
// POST /Users/AuthenticateByName → { AccessToken, User: { Id, Name } }
//
// Async via http_submit; on_login_response runs on the main thread.
//
Login_User :: struct {
Id: string;
Name: string;
}
Login_Response :: struct {
AccessToken: string;
User: Login_User;
}
jellyfin_login_async :: (c: *Jellyfin_Client) {
if c.login_pending return;
c.login_pending = true;
body := tprint("{\"Username\":\"%\",\"Pw\":\"%\"}", c.username, c.password);
log_info("auth: logging in as %", c.username);
http_submit("POST", "/Users/AuthenticateByName", body, on_done=on_login_response);
}
jellyfin_logout :: (c: *Jellyfin_Client) {
free(c.auth_token);
free(c.user_id);
c.auth_token = "";
c.user_id = "";
c.logged_in = false;
config_clear();
}
#scope_file
on_login_response :: (task: *Http_Task) {
app.jellyfin.login_pending = false;
if !task.response.ok {
log_error("login failed: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count)));
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Login_Response);
if !ok {
log_error("login: failed to parse JSON");
return;
}
app.jellyfin.auth_token = copy_string(parsed.AccessToken);
app.jellyfin.user_id = copy_string(parsed.User.Id);
app.jellyfin.logged_in = true;
log_info("auth: logged in as % (id=%)", parsed.User.Name, app.jellyfin.user_id);
config_save();
app.current_view = .LIBRARY;
library_refresh_artists();
}

129
src/jellyfin/client.jai Normal file
View File

@ -0,0 +1,129 @@
//
// HTTP client for Jellyfin. Wraps libcurl with a write-to-builder callback so
// each request returns the response body as a string.
//
// We follow Jellyfin's auth scheme: every authenticated request carries the
// `Authorization: MediaBrowser Token="<token>"` header (along with a Client
// fingerprint).
//
CLIENT_NAME :: "player";
CLIENT_VERSION :: "0.0.1";
DEVICE_NAME :: "player";
DEVICE_ID :: "player-dev-device"; // TODO: persist a real id per install
Jellyfin_Client :: struct {
server_url: string;
username: string;
password: string;
auth_token: string;
user_id: string;
logged_in: bool;
login_pending: bool;
}
jellyfin_client_init :: (c: *Jellyfin_Client) {
// Curl globals are managed lazily on first request.
}
jellyfin_client_shutdown :: (c: *Jellyfin_Client) {
// Nothing yet.
}
//
// Request helpers
//
Http_Response :: struct {
status_code: int;
body: string;
ok: bool;
}
build_auth_header :: (c: *Jellyfin_Client) -> string {
if c.logged_in && c.auth_token {
return tprint(
"MediaBrowser Client=\"%\", Device=\"%\", DeviceId=\"%\", Version=\"%\", Token=\"%\"",
CLIENT_NAME, DEVICE_NAME, DEVICE_ID, CLIENT_VERSION, c.auth_token);
}
return tprint(
"MediaBrowser Client=\"%\", Device=\"%\", DeviceId=\"%\", Version=\"%\"",
CLIENT_NAME, DEVICE_NAME, DEVICE_ID, CLIENT_VERSION);
}
http_get :: (c: *Jellyfin_Client, path: string) -> Http_Response {
return http_request(c, "GET", path, "");
}
http_post :: (c: *Jellyfin_Client, path: string, json_body: string) -> Http_Response {
return http_request(c, "POST", path, json_body);
}
#scope_file
Write_Buffer :: struct {
builder: String_Builder;
}
write_callback :: (contents: *u8, count: u64, size: u64, user: *Write_Buffer) -> u64 #c_call {
total := count * size;
push_context {
append(*user.builder, contents, cast(s64) total);
}
return total;
}
http_request :: (c: *Jellyfin_Client, method: string, path: string, body: string) -> Http_Response {
using Curl;
response: Http_Response;
handle := curl_easy_init();
if !handle {
log_error("curl_easy_init failed");
return response;
}
defer curl_easy_cleanup(handle);
url := tprint("%/%", c.server_url, trim_left(path, "/"));
curl_easy_setopt(handle, CURLoption.URL, temp_c_string(url));
buf: Write_Buffer;
curl_easy_setopt(handle, CURLoption.WRITEFUNCTION, write_callback);
curl_easy_setopt(handle, CURLoption.WRITEDATA, *buf);
headers: *curl_slist;
auth := build_auth_header(c);
headers = curl_slist_append(headers, temp_c_string(tprint("X-Emby-Authorization: %", auth)));
headers = curl_slist_append(headers, temp_c_string("Accept: application/json"));
if method == "POST" || method == "PUT" {
headers = curl_slist_append(headers, temp_c_string("Content-Type: application/json"));
curl_easy_setopt(handle, CURLoption.POSTFIELDS, temp_c_string(body));
curl_easy_setopt(handle, CURLoption.POSTFIELDSIZE, cast(s64) body.count);
}
if method == "GET" {
// default
} else if method != "POST" {
curl_easy_setopt(handle, CURLoption.CUSTOMREQUEST, temp_c_string(method));
}
curl_easy_setopt(handle, CURLoption.HTTPHEADER, headers);
defer curl_slist_free_all(headers);
err := curl_easy_perform(handle);
if err != .OK {
msg := to_string(curl_easy_strerror(err));
log_error("curl % %: %", method, url, msg);
return response;
}
code: s64;
curl_easy_getinfo(handle, CURLINFO.RESPONSE_CODE, *code);
response.status_code = code;
response.body = builder_to_string(*buf.builder);
response.ok = code >= 200 && code < 300;
return response;
}

25
src/jellyfin/images.jai Normal file
View File

@ -0,0 +1,25 @@
//
// Artist / album image fetch. Returns raw bytes that gfx/images.jai can decode
// with stb_image and upload to a Simp.Texture.
//
// Endpoints:
// GET /Items/{itemId}/Images/Primary — square thumb/album art
// GET /Items/{itemId}/Images/Backdrop/0 — 16:9 artist backdrop
//
fetch_primary_image :: (c: *Jellyfin_Client, item_id: string) -> string, bool {
if !c.logged_in return "", false;
path := tprint("/Items/%/Images/Primary", item_id);
resp := http_get(c, path);
if !resp.ok return "", false;
return resp.body, true;
}
fetch_backdrop_image :: (c: *Jellyfin_Client, item_id: string) -> string, bool {
if !c.logged_in return "", false;
// Backdrop/0 fetches the first (usually highest-rated) backdrop image
path := tprint("/Items/%/Images/Backdrop/0?fillWidth=1920&fillHeight=1080&quality=85", item_id);
resp := http_get(c, path);
if !resp.ok return "", false;
return resp.body, true;
}

6
src/jellyfin/index.jai Normal file
View File

@ -0,0 +1,6 @@
#load "client.jai";
#load "async.jai";
#load "auth.jai";
#load "library.jai";
#load "images.jai";
#load "stream.jai";

240
src/jellyfin/library.jai Normal file
View File

@ -0,0 +1,240 @@
//
// Library browsing — async. The UI calls library_refresh_artists /
// library_select_artist / library_select_album, which submit HTTP tasks.
// When each task completes the on_done callback parses JSON and writes the
// result into app.library on the main thread.
//
// Stale-result guard: each "select" stores a generation id. Late callbacks
// compare against the current id and discard if the user has navigated
// elsewhere.
//
Artist :: struct {
id: string;
name: string;
}
Album :: struct {
id: string;
name: string;
artist: string;
artist_id: string;
}
Track :: struct {
id: string;
name: string;
album: string;
album_id: string;
artist: string;
artist_id: string;
duration_ticks: s64;
index_number: int;
}
Library_State :: struct {
artists: [..] Artist;
albums: [..] Album;
tracks: [..] Track;
selected_artist_id: string;
selected_album_id: string;
artists_loaded: bool;
albums_loaded: bool;
tracks_loaded: bool;
artists_loading: bool;
albums_loading: bool;
tracks_loading: bool;
// Generation counters used by the async callbacks to discard stale
// results when the user has navigated away.
albums_request_gen: int;
tracks_request_gen: int;
artists_scroll: float;
albums_scroll: float;
tracks_scroll: float;
}
#scope_module
Item_Summary :: struct {
Id: string;
Name: string;
Album: string;
AlbumId: string;
AlbumArtist: string;
AlbumArtistId: string;
RunTimeTicks: s64;
IndexNumber: int;
}
Items_Response :: struct {
Items: [..] Item_Summary;
TotalRecordCount: int;
}
#scope_export
//
// Public — kick off async fetches.
//
library_refresh_artists :: () {
if app.library.artists_loading return;
app.library.artists_loading = true;
path := tprint("/Users/%/Items?IncludeItemTypes=MusicArtist&Recursive=true&SortBy=SortName&Limit=2000", app.jellyfin.user_id);
log_info("library: fetching artists");
http_submit("GET", path, on_done=on_artists_loaded);
}
library_select_artist :: (artist_id: string) {
if app.library.selected_artist_id == artist_id && app.library.albums_loaded return;
free(app.library.selected_artist_id);
app.library.selected_artist_id = copy_string(artist_id);
free_albums(*app.library.albums);
app.library.albums_loaded = false;
app.library.albums_loading = true;
app.library.albums_request_gen += 1;
app.library.albums_scroll = 0;
free_tracks(*app.library.tracks);
free(app.library.selected_album_id);
app.library.selected_album_id = "";
app.library.tracks_loaded = false;
app.library.tracks_loading = false;
app.library.tracks_scroll = 0;
path := tprint("/Users/%/Items?IncludeItemTypes=MusicAlbum&ArtistIds=%&Recursive=true&SortBy=ProductionYear,SortName&Limit=2000", app.jellyfin.user_id, artist_id);
log_info("library: fetching albums for %", artist_id);
http_submit("GET", path, on_done=on_albums_loaded, user_data=cast(*void) app.library.albums_request_gen);
}
library_select_album :: (album_id: string) {
if app.library.selected_album_id == album_id && app.library.tracks_loaded return;
free(app.library.selected_album_id);
app.library.selected_album_id = copy_string(album_id);
free_tracks(*app.library.tracks);
app.library.tracks_loaded = false;
app.library.tracks_loading = true;
app.library.tracks_request_gen += 1;
app.library.tracks_scroll = 0;
path := tprint("/Users/%/Items?ParentId=%&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName&Limit=2000", app.jellyfin.user_id, album_id);
log_info("library: fetching tracks for %", album_id);
http_submit("GET", path, on_done=on_tracks_loaded, user_data=cast(*void) app.library.tracks_request_gen);
}
#scope_file
//
// Callbacks — run on the main thread once the worker thread finishes.
//
on_artists_loaded :: (task: *Http_Task) {
app.library.artists_loading = false;
if !task.response.ok {
log_error("artists: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count)));
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response);
if !ok {
log_error("artists: json parse failed");
return;
}
free_artists(*app.library.artists);
for parsed.Items {
a: Artist;
a.id = copy_string(it.Id);
a.name = copy_string(it.Name);
array_add(*app.library.artists, a);
}
app.library.artists_loaded = true;
log_info("library: % artists loaded", app.library.artists.count);
// Prefetch every artist thumb. The image cache caps concurrency at 4
// so this just queues — it doesn't blow up the network.
for app.library.artists image_request(it.id, .THUMB);
}
on_albums_loaded :: (task: *Http_Task) {
gen := cast(int) task.user_data;
if gen != app.library.albums_request_gen return; // user moved on; discard
app.library.albums_loading = false;
if !task.response.ok {
log_error("albums: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count)));
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response);
if !ok {
log_error("albums: json parse failed");
return;
}
free_albums(*app.library.albums);
for parsed.Items {
a: Album;
a.id = copy_string(it.Id);
a.name = copy_string(it.Name);
a.artist = copy_string(it.AlbumArtist);
a.artist_id = copy_string(it.AlbumArtistId);
array_add(*app.library.albums, a);
}
app.library.albums_loaded = true;
log_info("library: % albums loaded", app.library.albums.count);
// Prefetch every album thumb up front so scrolling is instant.
for app.library.albums image_request(it.id, .THUMB);
}
on_tracks_loaded :: (task: *Http_Task) {
gen := cast(int) task.user_data;
if gen != app.library.tracks_request_gen return;
app.library.tracks_loading = false;
if !task.response.ok {
log_error("tracks: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count)));
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response);
if !ok {
log_error("tracks: json parse failed");
return;
}
free_tracks(*app.library.tracks);
for parsed.Items {
t: Track;
t.id = copy_string(it.Id);
t.name = copy_string(it.Name);
t.album = copy_string(it.Album);
t.album_id = copy_string(it.AlbumId);
t.artist = copy_string(it.AlbumArtist);
t.artist_id = copy_string(it.AlbumArtistId);
t.duration_ticks = it.RunTimeTicks;
t.index_number = it.IndexNumber;
array_add(*app.library.tracks, t);
}
app.library.tracks_loaded = true;
log_info("library: % tracks loaded", app.library.tracks.count);
}
free_artists :: (xs: *[..] Artist) {
for xs.* { free(it.id); free(it.name); }
array_reset(xs);
}
free_albums :: (xs: *[..] Album) {
for xs.* { free(it.id); free(it.name); free(it.artist); free(it.artist_id); }
array_reset(xs);
}
free_tracks :: (xs: *[..] Track) {
for xs.* { free(it.id); free(it.name); free(it.album); free(it.album_id); free(it.artist); free(it.artist_id); }
array_reset(xs);
}

18
src/jellyfin/stream.jai Normal file
View File

@ -0,0 +1,18 @@
//
// Build a streaming URL for a track. Jellyfin has /Audio/{id}/universal which
// transcodes if needed, but for now we'll prefer /Audio/{id}/stream.{ext} and
// transcode-fallback later.
//
build_stream_url :: (c: *Jellyfin_Client, track_id: string) -> string {
return tprint("%/Audio/%/stream?api_key=%", c.server_url, track_id, c.auth_token);
}
// Download an entire track into memory, suitable to hand off to
// Sound_Player.load_audio_data().
download_track_bytes :: (c: *Jellyfin_Client, track_id: string) -> string, bool {
path := tprint("/Audio/%/stream", track_id);
resp := http_get(c, path);
if !resp.ok return "", false;
return resp.body, true;
}

23
src/main.jai Normal file
View File

@ -0,0 +1,23 @@
//
// player — jellyfin music player
//
// All actual code lives under sibling folders. Each folder owns an index.jai
// that #loads its files, so adding code = drop a file + one #load line.
//
// Order: imports first (so types are in scope), then util, then everything else.
//
#load "core/imports.jai";
#load "util/index.jai";
#load "core/index.jai";
#load "jellyfin/index.jai";
#load "audio/index.jai";
#load "gfx/index.jai";
#load "ui/index.jai";
main :: () {
app_init();
defer app_shutdown();
run_main_loop();
}

18
src/ui/fonts.jai Normal file
View File

@ -0,0 +1,18 @@
//
// Font loading. Sized relative to window height so the UI scales sanely on
// resize. Called from app_init() and on window-resize.
//
FONT_DIR :: "data/fonts";
FONT_FILE :: "OpenSans-BoldItalic.ttf";
init_fonts :: () {
h := app.window_height;
app.title_font = Simp.get_font_at_size(FONT_DIR, FONT_FILE, h / 8);
app.big_font = Simp.get_font_at_size(FONT_DIR, FONT_FILE, h / 18);
app.row_font = Simp.get_font_at_size(FONT_DIR, FONT_FILE, h / 24);
app.button_font = Simp.get_font_at_size(FONT_DIR, FONT_FILE, h / 32);
app.body_font = Simp.get_font_at_size(FONT_DIR, FONT_FILE, h / 40);
if !app.title_font log_error("font load failed: %", FONT_FILE);
}

3
src/ui/index.jai Normal file
View File

@ -0,0 +1,3 @@
#load "fonts.jai";
#load "theme.jai";
#load "views/index.jai";

13
src/ui/theme.jai Normal file
View File

@ -0,0 +1,13 @@
//
// 2000s album-skin theme. We start from the GetRect "Blood Vampire" / "Nimbus"
// vibe but you can pick any of the bundled themes via app.current_theme.
//
// Eventually we'll write a fully custom Overall_Theme here with chunky bevels,
// big rounding, and neon accents. For MVP we lean on the existing theme procs
// and let the visualizer shader carry the aesthetic.
//
ui_theme_init :: () {
// Default_Themes.Blood_Vampire is the loudest stock theme — fits the brief.
app.current_theme = xx Default_Themes.Blood_Vampire;
}

3
src/ui/views/index.jai Normal file
View File

@ -0,0 +1,3 @@
#load "login_view.jai";
#load "library_view.jai";
#load "now_playing_view.jai";

View File

@ -0,0 +1,246 @@
//
// Library browser. Three scrollable columns: artists | albums | tracks.
// Click an artist → fetch its albums. Click an album → fetch its tracks.
// Click a track → play it (and queue the rest of the album).
//
// Top bar: title + logout. Bottom bar: now-playing strip + transport controls.
//
draw_library_view :: () {
w := cast(float) app.window_width;
h := cast(float) app.window_height;
// Margins.
pad := h * 0.02;
top_h := h * 0.10; // header strip
bottom_h := h * 0.10; // transport strip
draw_library_header(0, 0, w, top_h);
draw_library_columns(pad, top_h, w - pad * 2, h - top_h - bottom_h);
draw_transport_strip(0, h - bottom_h, w, bottom_h);
}
#scope_file
draw_library_header :: (x: float, y: float, w: float, h: float) {
label_theme := app.theme.label_theme;
label_theme.font = app.title_font;
label_theme.alignment = .Left;
label_theme.text_color = .{1, 0.4, 0.8, 1};
label(get_rect(x + h * 0.4, y + h * 0.1, w * 0.5, h * 0.8), "PLAYER", *label_theme);
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
bw := w * 0.10;
bh := h * 0.5;
if button(get_rect(x + w - bw - h * 0.4, y + h * 0.25, bw, bh), "logout", *button_theme) {
jellyfin_logout(*app.jellyfin);
free(app.jellyfin.password);
app.jellyfin.password = copy_string("");
app.current_view = .LOGIN;
}
}
draw_library_columns :: (x: float, y: float, w: float, h: float) {
pad := w * 0.01;
col_w := (w - pad * 2) / 3.0;
artists_x := x;
albums_x := x + col_w + pad;
tracks_x := x + col_w * 2 + pad * 2;
draw_artists_column(artists_x, y, col_w, h);
draw_albums_column (albums_x, y, col_w, h);
draw_tracks_column (tracks_x, y, col_w, h);
}
draw_artists_column :: (x: float, y: float, w: float, h: float) {
header_h := app.button_font.character_height * 1.5;
draw_column_header(x, y, w, header_h, "ARTISTS");
region_y := y + header_h;
region_h := h - header_h;
region, inside := begin_scrollable_region(get_rect(x, region_y, w, region_h));
button_theme := app.theme.button_theme;
button_theme.font = app.row_font;
button_theme.label_theme.alignment = .Left;
row_h := app.row_font.character_height * 1.8;
s := inside;
s.h = row_h;
s.y -= app.library.artists_scroll;
for app.library.artists {
active := app.library.selected_artist_id == it.id;
bt := button_theme;
if active {
bt.surface_color = .{0.35, 0.10, 0.45, 1};
bt.text_color = .{1, 1, 1, 1};
}
thumb_size := row_h;
thumb_pad := row_h * 0.10;
img := image_request(it.id, .THUMB);
draw_image(s.x, s.y, thumb_size, row_h, img);
btn_x := s.x + thumb_size + thumb_pad;
btn_w := s.w - thumb_size - thumb_pad;
if button(get_rect(btn_x, s.y, btn_w, row_h), it.name, *bt, identifier=it_index) {
library_select_artist(it.id);
}
s.y += row_h * 1.05;
}
end_scrollable_region(region, s.x + s.w, s.y, *app.library.artists_scroll);
}
draw_albums_column :: (x: float, y: float, w: float, h: float) {
header_h := app.button_font.character_height * 1.5;
draw_column_header(x, y, w, header_h, "ALBUMS");
region_y := y + header_h;
region_h := h - header_h;
region, inside := begin_scrollable_region(get_rect(x, region_y, w, region_h));
button_theme := app.theme.button_theme;
button_theme.font = app.row_font;
button_theme.label_theme.alignment = .Left;
row_h := app.row_font.character_height * 1.8;
s := inside;
s.h = row_h;
s.y -= app.library.albums_scroll;
for app.library.albums {
active := app.library.selected_album_id == it.id;
bt := button_theme;
if active {
bt.surface_color = .{0.35, 0.10, 0.45, 1};
bt.text_color = .{1, 1, 1, 1};
}
thumb_size := row_h;
thumb_pad := row_h * 0.10;
img := image_request(it.id, .THUMB);
draw_image(s.x, s.y, thumb_size, row_h, img);
btn_x := s.x + thumb_size + thumb_pad;
btn_w := s.w - thumb_size - thumb_pad;
if button(get_rect(btn_x, s.y, btn_w, row_h), it.name, *bt, identifier=it_index) {
library_select_album(it.id);
}
s.y += row_h * 1.05;
}
end_scrollable_region(region, s.x + s.w, s.y, *app.library.albums_scroll);
}
draw_tracks_column :: (x: float, y: float, w: float, h: float) {
header_h := app.button_font.character_height * 1.5;
draw_column_header(x, y, w, header_h, "TRACKS");
region_y := y + header_h;
region_h := h - header_h;
region, inside := begin_scrollable_region(get_rect(x, region_y, w, region_h));
button_theme := app.theme.button_theme;
button_theme.font = app.row_font;
button_theme.label_theme.alignment = .Left;
row_h := app.row_font.character_height * 1.8;
s := inside;
s.h = row_h;
s.y -= app.library.tracks_scroll;
for app.library.tracks {
playing := app.current_stream && app.current_track.id == it.id;
bt := button_theme;
if playing {
bt.surface_color = .{0.10, 0.40, 0.55, 1};
bt.text_color = .{1, 1, 1, 1};
}
label_text := tprint("%. %", it.index_number, it.name);
if button(s, label_text, *bt, identifier=it_index) {
queue_clear();
for tr, idx: app.library.tracks {
queue_add(tr);
if tr.id == it.id queue.current = idx;
}
audio_play_track(it);
app.current_view = .NOW_PLAYING;
}
s.y += row_h * 1.05;
}
end_scrollable_region(region, s.x + s.w, s.y, *app.library.tracks_scroll);
}
draw_column_header :: (x: float, y: float, w: float, h: float, text: string) {
label_theme := app.theme.label_theme;
label_theme.font = app.button_font;
label_theme.alignment = .Left;
label_theme.text_color = .{0.6, 0.9, 1.0, 1};
label(get_rect(x, y, w, h), text, *label_theme);
}
draw_transport_strip :: (x: float, y: float, w: float, h: float) {
// Now-playing readout (left).
label_theme := app.theme.label_theme;
label_theme.font = app.button_font;
label_theme.alignment = .Left;
label_theme.text_color = .{1, 1, 1, 1};
title := ifx app.current_stream
then tprint("% — % [%]", app.current_track.artist, app.current_track.name, app.current_format)
else "—";
label(get_rect(x + h * 0.3, y + h * 0.2, w * 0.40, h * 0.6), title, *label_theme);
// Volume slider (middle).
{
slider_theme := app.theme.slider_theme;
slider_theme.foreground.font = app.body_font;
vw := w * 0.18;
vh := h * 0.55;
vx := x + w * 0.42;
vy := y + h * 0.225;
before := app.master_volume;
slider(
get_rect(vx, vy, vw, vh),
*app.master_volume,
0.0, 1.0, 0.05,
*slider_theme,
"vol ", "",
identifier=99,
);
if before != app.master_volume config_save();
}
// Transport buttons (right cluster).
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
bw := h * 1.3;
bh := h * 0.55;
spacing := h * 0.15;
cluster_w := bw * 4 + spacing * 3;
bx := x + w - cluster_w - h * 0.3;
by := y + h * 0.225;
if button(get_rect(bx, by, bw, bh), "<<", *button_theme) queue_prev();
bx += bw + spacing;
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
if button(get_rect(bx, by, bw, bh), pp_label, *button_theme) audio_toggle_pause();
bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), ">>", *button_theme) queue_next();
bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), "now", *button_theme) app.current_view = .NOW_PLAYING;
}

View File

@ -0,0 +1,87 @@
//
// Login screen. Three text inputs (server URL, username, password) and a
// big chunky CONNECT button.
//
draw_login_view :: () {
w := cast(float) app.window_width;
h := cast(float) app.window_height;
k := h * 0.05;
// Title.
{
label_theme := app.theme.label_theme;
label_theme.font = app.title_font;
label_theme.alignment = .Center;
label_theme.text_color = .{1, 0.4, 0.8, 1};
r := get_rect(0, h * 0.06, w, app.title_font.character_height * 1.2);
label(r, "PLAYER", *label_theme);
}
{
label_theme := app.theme.label_theme;
label_theme.font = app.body_font;
label_theme.alignment = .Center;
label_theme.text_color = .{0.7, 0.7, 0.9, 1};
r := get_rect(0, h * 0.06 + app.title_font.character_height * 1.2, w, app.body_font.character_height * 2.0);
label(r, "a jellyfin music player", *label_theme);
}
// Form column.
field_w := min(w * 0.5, 12.0 * k);
field_x := (w - field_w) * 0.5;
field_h := app.button_font.character_height * 1.7;
text_theme := app.theme.text_input_theme;
text_theme.font = app.button_font;
label_theme := app.theme.label_theme;
label_theme.font = app.body_font;
label_theme.alignment = .Left;
line_h := app.body_font.character_height * 1.2;
gap := field_h * 1.3;
cursor_y := h * 0.40;
text_field(field_x, *cursor_y, field_w, line_h, field_h, "server URL", *app.jellyfin.server_url, *label_theme, *text_theme);
cursor_y += gap - line_h - field_h;
text_field(field_x, *cursor_y, field_w, line_h, field_h, "username", *app.jellyfin.username, *label_theme, *text_theme);
cursor_y += gap - line_h - field_h;
text_field(field_x, *cursor_y, field_w, line_h, field_h, "password", *app.jellyfin.password, *label_theme, *text_theme);
cursor_y += gap - line_h - field_h;
// CONNECT button.
cursor_y += k * 0.5;
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
label_text := ifx app.jellyfin.login_pending then "CONNECTING..." else "CONNECT";
if button(get_rect(field_x, cursor_y, field_w, field_h * 1.2), label_text, *button_theme) {
if !app.jellyfin.login_pending {
jellyfin_login_async(*app.jellyfin);
}
}
}
//
// Stack a label + text_input. Each call gets its own widget identity by
// passing the caller's source location through to the widgets — same trick
// GetRect uses internally. The identifier= disambiguates the two widgets
// drawn at the same loc.
//
text_field :: (
x: float, y: *float, w: float, label_h: float, input_h: float,
label_text: string, value: *string,
label_theme: *Label_Theme, text_theme: *Text_Input_Theme,
loc := #caller_location,
) {
label(get_rect(x, y.*, w, label_h), label_text, label_theme);
y.* += label_h;
action, new_text := text_input(get_rect(x, y.*, w, input_h), value.*, text_theme, loc=loc);
if action & .ENTERED modify_string(value, new_text);
y.* += input_h;
}
modify_string :: (ps: *string, new_value: string) {
free(ps.*);
ps.* = copy_string(new_value);
}

View File

@ -0,0 +1,188 @@
//
// Now playing — the marquee screen. Visualizer shader runs behind this in
// window.jai, so the foreground here is just track info, transport, and
// the seek/volume sliders.
//
// Artist backdrop (if available) renders full-screen behind everything with
// a dark tint overlay so the UI remains readable.
//
draw_now_playing_view :: () {
w := cast(float) app.window_width;
h := cast(float) app.window_height;
k := h * 0.05;
has_track := app.current_stream != null || app.current_track.id.count > 0;
//
// Full-screen artist backdrop (drawn first, behind everything).
// We tint it dark so the UI text remains readable.
//
if has_track && app.current_track.artist_id.count > 0 {
backdrop := image_request(app.current_track.artist_id, .BACKDROP);
// Draw backdrop full-screen
draw_image(0, 0, w, h, backdrop);
// Dark overlay: 60% black so the UI pops against busy backdrops
Simp.set_shader_for_color();
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 0.60});
} else {
// No track playing — solid background
Simp.set_shader_for_color();
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 1});
}
// Top-left: back to library.
{
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
if button(get_rect(k * 0.6, k * 0.6, w * 0.16, k), "library", *button_theme) {
app.current_view = .LIBRARY;
}
}
// Layout: art on the left, info column on the right.
art_size := min(w * 0.32, h * 0.55);
art_x := w * 0.06;
art_y := h * 0.12;
info_x := art_x + art_size + w * 0.04;
info_w := w - info_x - w * 0.06;
if has_track {
img := image_request(app.current_track.album_id, .LARGE);
draw_image(art_x, art_y, art_size, art_size, img);
} else {
// Placeholder slot so the layout doesn't shift on first launch.
Simp.set_shader_for_color();
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
}
// Track name (large).
text := ifx has_track app.current_track.name else "— silence —";
{
label_theme := app.theme.label_theme;
label_theme.font = app.title_font;
label_theme.alignment = .Left;
label_theme.text_color = .{1, 0.4, 0.8, 1};
r := get_rect(info_x, art_y, info_w, app.title_font.character_height * 1.4);
label(r, text, *label_theme);
}
if has_track {
label_theme := app.theme.label_theme;
label_theme.font = app.big_font;
label_theme.alignment = .Left;
label_theme.text_color = .{0.8, 0.8, 1, 1};
r := get_rect(info_x, art_y + app.title_font.character_height * 1.5, info_w, app.big_font.character_height * 1.4);
label(r, app.current_track.artist, *label_theme);
label_theme.font = app.body_font;
label_theme.text_color = .{0.6, 0.6, 0.8, 1};
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
label(r, app.current_track.album, *label_theme);
// Format chip.
label_theme.text_color = .{0.5, 0.9, 0.7, 1};
format_text := tprint("[%]", app.current_format);
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6 + app.body_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
label(r, format_text, *label_theme);
}
// Seek slider — replaces the old hand-drawn position strip.
if has_track && app.current_stream && app.current_stream.sound_data {
rate := cast(float) app.current_stream.sound_data.sampling_rate;
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
if total <= 0 total = max(cast(float) app.current_stream.play_cursor / rate + 1, 1);
// Each frame, mirror play_cursor → scrub_seconds before the slider
// draws. If the user drags, the slider mutates scrub_seconds in
// place; we read `changed` and seek.
app.scrub_seconds = clamp(cast(float) app.current_stream.play_cursor / rate, 0, total);
slider_theme := app.theme.slider_theme;
slider_theme.foreground.font = app.button_font;
slider_w := w * 0.7;
slider_h := k * 0.6;
slider_x := (w - slider_w) * 0.5;
slider_y := h * 0.66;
changed, _ := slider(
get_rect(slider_x, slider_y, slider_w, slider_h),
*app.scrub_seconds,
0.0, total, 1.0,
*slider_theme,
"", "s",
);
if changed {
audio_seek_seconds(app.scrub_seconds);
}
// Time labels under the slider.
time_theme := app.theme.label_theme;
time_theme.font = app.body_font;
time_theme.text_color = .{0.7, 0.7, 0.85, 1};
time_theme.alignment = .Left;
label(get_rect(slider_x, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(app.scrub_seconds), *time_theme);
time_theme.alignment = .Right;
label(get_rect(slider_x + slider_w * 0.5, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(total), *time_theme);
}
// Transport buttons cluster, centered.
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
bw := k * 2.6;
bh := k * 1.6;
spacing := k * 0.4;
total_btn_w := bw * 3 + spacing * 2;
bx := (w - total_btn_w) * 0.5;
by := h * 0.78;
if button(get_rect(bx, by, bw, bh), "<<", *button_theme, identifier=1) queue_prev();
bx += bw + spacing;
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
if button(get_rect(bx, by, bw, bh), pp_label, *button_theme, identifier=2) audio_toggle_pause();
bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), ">>", *button_theme, identifier=3) queue_next();
// Volume slider, bottom-right.
{
slider_theme := app.theme.slider_theme;
slider_theme.foreground.font = app.body_font;
vw := w * 0.18;
vh := k * 0.55;
vx := w - vw - k * 0.6;
vy := h - vh - k * 0.6;
before := app.master_volume;
slider(
get_rect(vx, vy, vw, vh),
*app.master_volume,
0.0, 1.0, 0.05,
*slider_theme,
"vol ", "",
identifier=99,
);
if before != app.master_volume {
// Persist on every change — config_save is cheap and the user
// expects volume to survive restarts.
config_save();
}
}
}
#scope_file
format_seconds :: (s: float) -> string {
if !(s >= 0) return "0:00"; // catches negatives and NaN
total := cast(int) s;
m := total / 60;
sec := total - m * 60;
return tprint("%:%", m, formatInt(sec, minimum_digits=2));
}

2
src/util/index.jai Normal file
View File

@ -0,0 +1,2 @@
#load "log.jai";
#load "path.jai";

22
src/util/log.jai Normal file
View File

@ -0,0 +1,22 @@
//
// Tiny logging shim. Wraps print() so we can later add levels/file output without
// touching call sites.
//
log_info :: (fmt: string, args: ..Any) {
print("[info] ");
print(fmt, ..args);
print("\n");
}
log_warn :: (fmt: string, args: ..Any) {
print("[warn] ");
print(fmt, ..args);
print("\n");
}
log_error :: (fmt: string, args: ..Any) {
print("[error] ");
print(fmt, ..args);
print("\n");
}

26
src/util/path.jai Normal file
View File

@ -0,0 +1,26 @@
//
// Find the data/ folder regardless of where the binary was launched.
//
// We try cwd, then the exe's directory, then exe_dir/.. (so running
// ./build/player from the project root works).
//
setup_data_directory :: () {
candidates: [..] string;
array_add(*candidates, ".");
exe_dir := path_strip_filename(get_path_of_running_executable());
array_add(*candidates, copy_string(exe_dir));
array_add(*candidates, tprint("%/..", exe_dir));
for candidates {
data_path := tprint("%/data", it);
if file_exists(data_path) {
set_working_directory(it);
log_info("working dir: %", it);
return;
}
}
log_warn("could not locate data/ — fonts and shaders may fail to load");
}