initial
This commit is contained in:
commit
c8c69366b5
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal 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
32
ai/aesthetic.md
Normal 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
52
ai/architecture.md
Normal 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
63
ai/decisions.md
Normal 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
35
ai/todo.md
Normal 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
73
build.jai
Normal 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
BIN
build/player
Executable file
Binary file not shown.
BIN
data/fonts/OpenSans-BoldItalic.ttf
Normal file
BIN
data/fonts/OpenSans-BoldItalic.ttf
Normal file
Binary file not shown.
1
lib/libcurl.so
Symbolic link
1
lib/libcurl.so
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/usr/lib/x86_64-linux-gnu/libcurl.so.4
|
||||||
16
modules/Jaison/.editorconfig
Normal file
16
modules/Jaison/.editorconfig
Normal 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
4
modules/Jaison/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.build/
|
||||||
|
*.dSYM
|
||||||
|
/modules
|
||||||
|
examples/example
|
||||||
3
modules/Jaison/.gitmodules
vendored
Normal file
3
modules/Jaison/.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "unicode_utils"]
|
||||||
|
path = unicode_utils
|
||||||
|
url = git@github.com:rluba/jai-unicode
|
||||||
21
modules/Jaison/LICENSE
Normal file
21
modules/Jaison/LICENSE
Normal 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.
|
||||||
391
modules/Jaison/examples/example.jai
Normal file
391
modules/Jaison/examples/example.jai
Normal 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
370
modules/Jaison/generic.jai
Normal 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 it’s 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 hasn’t 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
219
modules/Jaison/module.jai
Normal 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
63
modules/Jaison/readme.md
Normal 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 don’t know the structure (or can’t 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 don’t 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
625
modules/Jaison/typed.jai
Normal 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";
|
||||||
|
|
||||||
59
modules/Jaison/unicode_utils/module.jai
Normal file
59
modules/Jaison/unicode_utils/module.jai
Normal 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";
|
||||||
BIN
modules/audio_decoders/linux/decoders.a
Normal file
BIN
modules/audio_decoders/linux/decoders.a
Normal file
Binary file not shown.
32
modules/audio_decoders/module.jai
Normal file
32
modules/audio_decoders/module.jai
Normal 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";
|
||||||
27
modules/audio_decoders/source/build.sh
Executable file
27
modules/audio_decoders/source/build.sh
Executable 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"
|
||||||
74
modules/audio_decoders/source/decoders.c
Normal file
74
modules/audio_decoders/source/decoders.c
Normal 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);
|
||||||
|
}
|
||||||
12536
modules/audio_decoders/source/dr_flac.h
Normal file
12536
modules/audio_decoders/source/dr_flac.h
Normal file
File diff suppressed because it is too large
Load Diff
4834
modules/audio_decoders/source/dr_mp3.h
Normal file
4834
modules/audio_decoders/source/dr_mp3.h
Normal file
File diff suppressed because it is too large
Load Diff
BIN
modules/stb_image/android/arm64/stb_image.a
Normal file
BIN
modules/stb_image/android/arm64/stb_image.a
Normal file
Binary file not shown.
BIN
modules/stb_image/android/arm64/stb_image.so
Normal file
BIN
modules/stb_image/android/arm64/stb_image.so
Normal file
Binary file not shown.
BIN
modules/stb_image/android/x64/stb_image.a
Normal file
BIN
modules/stb_image/android/x64/stb_image.a
Normal file
Binary file not shown.
BIN
modules/stb_image/android/x64/stb_image.so
Normal file
BIN
modules/stb_image/android/x64/stb_image.so
Normal file
Binary file not shown.
145
modules/stb_image/bindings.jai
Normal file
145
modules/stb_image/bindings.jai
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
151
modules/stb_image/generate.jai
Normal file
151
modules/stb_image/generate.jai
Normal 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";
|
||||||
|
|
||||||
BIN
modules/stb_image/linux/stb_image.a
Normal file
BIN
modules/stb_image/linux/stb_image.a
Normal file
Binary file not shown.
BIN
modules/stb_image/linux/stb_image.so
Executable file
BIN
modules/stb_image/linux/stb_image.so
Executable file
Binary file not shown.
BIN
modules/stb_image/macos/stb_image.a
Normal file
BIN
modules/stb_image/macos/stb_image.a
Normal file
Binary file not shown.
BIN
modules/stb_image/macos/stb_image.dylib
Normal file
BIN
modules/stb_image/macos/stb_image.dylib
Normal file
Binary file not shown.
12
modules/stb_image/module.jai
Normal file
12
modules/stb_image/module.jai
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
11
modules/stb_image/source/stb_image.c
Normal file
11
modules/stb_image/source/stb_image.c
Normal 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"
|
||||||
7985
modules/stb_image/source/stb_image.h
Normal file
7985
modules/stb_image/source/stb_image.h
Normal file
File diff suppressed because it is too large
Load Diff
BIN
modules/stb_image/windows/stb_image.dll
Normal file
BIN
modules/stb_image/windows/stb_image.dll
Normal file
Binary file not shown.
BIN
modules/stb_image/windows/stb_image.lib
Normal file
BIN
modules/stb_image/windows/stb_image.lib
Normal file
Binary file not shown.
109
src/audio/analysis.jai
Normal file
109
src/audio/analysis.jai
Normal 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
110
src/audio/decoders.jai
Normal 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
82
src/audio/fft.jai
Normal 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
5
src/audio/index.jai
Normal 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
129
src/audio/player.jai
Normal 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
36
src/audio/queue.jai
Normal 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
99
src/core/app.jai
Normal 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
87
src/core/config.jai
Normal 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
24
src/core/imports.jai
Normal 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
6
src/core/index.jai
Normal 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
7
src/core/time.jai
Normal 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
71
src/core/window.jai
Normal 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
240
src/gfx/images.jai
Normal 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
2
src/gfx/index.jai
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#load "shaders.jai";
|
||||||
|
#load "images.jai";
|
||||||
90
src/gfx/shaders.jai
Normal file
90
src/gfx/shaders.jai
Normal 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
165
src/jellyfin/async.jai
Normal 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
57
src/jellyfin/auth.jai
Normal 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
129
src/jellyfin/client.jai
Normal 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
25
src/jellyfin/images.jai
Normal 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
6
src/jellyfin/index.jai
Normal 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
240
src/jellyfin/library.jai
Normal 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
18
src/jellyfin/stream.jai
Normal 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
23
src/main.jai
Normal 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
18
src/ui/fonts.jai
Normal 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
3
src/ui/index.jai
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#load "fonts.jai";
|
||||||
|
#load "theme.jai";
|
||||||
|
#load "views/index.jai";
|
||||||
13
src/ui/theme.jai
Normal file
13
src/ui/theme.jai
Normal 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
3
src/ui/views/index.jai
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#load "login_view.jai";
|
||||||
|
#load "library_view.jai";
|
||||||
|
#load "now_playing_view.jai";
|
||||||
246
src/ui/views/library_view.jai
Normal file
246
src/ui/views/library_view.jai
Normal 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;
|
||||||
|
}
|
||||||
87
src/ui/views/login_view.jai
Normal file
87
src/ui/views/login_view.jai
Normal 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);
|
||||||
|
}
|
||||||
188
src/ui/views/now_playing_view.jai
Normal file
188
src/ui/views/now_playing_view.jai
Normal 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
2
src/util/index.jai
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#load "log.jai";
|
||||||
|
#load "path.jai";
|
||||||
22
src/util/log.jai
Normal file
22
src/util/log.jai
Normal 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
26
src/util/path.jai
Normal 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");
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user