improvements

This commit is contained in:
Tuomas Katajisto 2026-05-04 19:50:59 +03:00
parent 19dc85821f
commit 7c694705db
52 changed files with 1383 additions and 26039 deletions

View File

@ -14,7 +14,7 @@ cd /home/katajisto/player
./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`.
`build.jai` is a metaprogram. It points the import path at `./modules` (vendored: `Jaison`) and falls back to the standard Jai modules at `/home/katajisto/bin/jai/modules`.
## Code layout
@ -36,7 +36,8 @@ Each folder has an `index.jai` that `#load`s every file in that folder. To add a
- **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
- **stb_image** (Jai stdlib) — single-header PNG/JPG decode for artist images
- **stb_vorbis** (Jai stdlib) — OGG decode for the (only) audio path
Cross-platform target: linux + macOS.

View File

@ -2,6 +2,30 @@
Append-only. Most recent first. Keep entries short — one paragraph max. Reference files with paths.
## 2026-05-01 — Single audio pipeline: OGG-only via stdlib stb_vorbis
Jellyfin transcodes everything to OGG Vorbis (server is beefy, transcode cost is irrelevant). On our side, `decode_ogg` (`src/audio/decoders.jai`, ~30 lines) calls `stb_vorbis_decode_memory` to produce one s16 PCM buffer that we hand to Sound_Player as `LINEAR_SAMPLE_ARRAY`. The visualizer reads from the *same* `sd.samples` around `play_cursor` (frames, scaled by `nchannels` to index the interleaved buffer) — no parallel stb_vorbis decoder, no `analysis_vorbis`/`analysis_ogg` state, no cursor desync between bars and audio. Killed: the `Audio_Format` enum, `detect_audio_format`, the dr_mp3/dr_flac branches, the `Sound.load_audio_data` OGG_COMPRESSED path, the `current_format` field on App, the `[%]` format suffix in the now-playing label.
## 2026-05-01 — Drop vendored audio_decoders + stb_image; use Jai stdlib
The dr_mp3/dr_flac C wrapper module (`modules/audio_decoders/`) and its build hook (`ensure_audio_decoders_built` in `build.jai``source/build.sh``decoders.a`) existed only to support MP3/FLAC. With OGG-only playback that's all dead. Deleted. Vendored `modules/stb_image/` was older than the stdlib copy (missing Android/NN_SWITCH2 OS dispatch) — also deleted; `Stb_Image :: #import "stb_image"` now resolves to the stdlib. Only thing still vendored is `Jaison` (no stdlib JSON parser). Result: no C build step, no static archive to manage, smaller binary (4.92 MB → 4.85 MB), `core/imports.jai` lost a line.
## 2026-05-01 — DeviceId is per-install, generated once and persisted
Jellyfin permits exactly one active access token per `DeviceId`. A hardcoded constant (`"player-dev-device"`) meant any second instance, second machine, or re-login flow silently revoked the prior saved token — root cause of the 401-on-launch bug we hit. A 32-char hex `device_id` is now generated on first run via `random_get()` (Random seeded from `current_time_monotonic()` in `app_init`), stored alongside the token in `config.json`, and reused forever. `ensure_device_id()` runs every launch so existing configs migrate in. `jellyfin_force_logout` (the on-401 path) deliberately keeps the device_id — burning it would orphan the device entry server-side.
## 2026-05-01 — `Authorization: MediaBrowser ...`, not `X-Emby-Authorization`
The legacy `X-Emby-Authorization` header is being removed in Jellyfin 12.0; admins on 10.11+ can already disable it via `EnableLegacyAuthorization=false`. Switched both `client.jai` (sync path) and `async.jai` (worker path) to the modern `Authorization` header. Same value format. Client name is `"Jellyfin Celica Music Player"` and Device is `"Celica"` so the device shows up sensibly in Dashboard → Devices.
## 2026-05-01 — Validate saved token at startup; auto-drop to login on 401
After `config_load()` succeeds, `app_init` fires `jellyfin_validate_session_async()` (a `GET /System/Info` probe) instead of jumping straight to library. On 200 we proceed; on 401 `jellyfin_force_logout()` clears the token, persists, and switches view to `.LOGIN` (preserving server_url/username/device_id for one-click re-auth). The library callbacks also check `status_code == 401` so a mid-session revoke from the dashboard doesn't leave the user on an empty library. On non-401 failures (network blip, server down) we keep the saved token and just route to login — never punish a transient error with a token wipe. Background: Jellyfin AccessTokens have no time-based expiry; they only die from same-DeviceId re-auth, password change, or admin revoke.
## 2026-05-01 — Quick Connect as a second login path
`src/jellyfin/quick_connect.jai` implements the three-step flow: `POST /QuickConnect/Initiate` → display 6-char code → poll `GET /QuickConnect/Connect?secret=…` every 3s → on `Authenticated:true`, `POST /Users/AuthenticateWithQuickConnect` with `{Secret}` to claim an `AccessToken`. Polling is driven by per-frame `jellyfin_quick_connect_pump()` in the main loop (no sleeping thread). State (`qc_state`, `qc_code`, `qc_secret`, `qc_poll_at`) lives on `Jellyfin_Client`; `draw_login_view` swaps between the password form and a "enter this code" panel based on `qc_state`. Lets the user log in without typing a password into the player. Field name `Code` shadows a Jai primitive, so the parse struct uses `UserCode: string; @JsonName(Code)` for Jaison's rename note.
## 2026-04-28 — Image cache with capped concurrency; on-main-thread texture upload
`gfx/images.jai` is a small async image cache. UI calls `image_request(item_id, size)` from the draw loop; it always returns an `*Image` immediately whose `.loaded` flips true once the bytes arrive. Concurrency is capped at `MAX_CONCURRENT_IMAGE_FETCHES = 4` via a pending queue (`image_pending`) drained by `image_pump()` once per frame — without this, a 500-album library would spawn 500 worker threads at first scroll. Decode + texture upload runs in the http callback (which is already main-thread per `jellyfin/async.jai`), satisfying OpenGL's no-cross-thread rule. Two cache buckets per item, keyed `t:<id>` (128 px thumb) and `l:<id>` (512 px). Sizing is requested from Jellyfin via `?fillHeight=N&fillWidth=N&quality=80` so we get a tight payload instead of full-resolution originals.

View File

@ -3,8 +3,8 @@
//
// Run with: jai build.jai
//
// Adds ./modules to the import path so we can vendor Jaison and stb_image
// alongside the standard Jai modules.
// Adds ./modules to the import path so we can vendor Jaison alongside the
// standard Jai modules.
//
#run build();
@ -35,9 +35,6 @@ build :: () {
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");
@ -47,27 +44,7 @@ build :: () {
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";

Binary file not shown.

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
modules/jai-mpris/example/main Executable file

Binary file not shown.

View File

@ -0,0 +1,63 @@
#import "Basic";
#import,file "../module.jai";
// State shared between the main loop and D-Bus callbacks.
// Callbacks are #c_call so they can't use Jai allocators directly —
// communicate via a plain struct instead.
Player_State :: struct {
should_play_pause : bool;
should_next : bool;
should_previous : bool;
}
on_play_pause :: (ud: *void) #c_call {
state := cast(*Player_State) ud;
state.should_play_pause = true;
}
on_next :: (ud: *void) #c_call {
(cast(*Player_State) ud).should_next = true;
}
on_previous :: (ud: *void) #c_call {
(cast(*Player_State) ud).should_previous = true;
}
main :: () {
player := mpris_player_create("ExamplePlayer", "Example Music Player");
if !player { log_error("Failed to create MPRIS player"); return; }
defer mpris_player_destroy(player);
state: Player_State;
mpris_on_play_pause(player, on_play_pause, *state);
mpris_on_next (player, on_next, *state);
mpris_on_previous (player, on_previous, *state);
mpris_set_playback_status(player, "Playing");
mpris_set_can_go_next (player, true);
mpris_set_can_go_previous(player, true);
meta: Mpris_Metadata;
meta.title = "Some Song";
meta.artist = "Some Artist";
meta.album = "Some Album";
meta.length_us = 210 * 1_000_000;
mpris_set_metadata(player, meta);
print("Player registered. Press Ctrl+C to stop.\n");
while true {
mpris_process(player);
if state.should_play_pause {
state.should_play_pause = false;
print("PlayPause\n");
}
if state.should_next {
state.should_next = false;
print("Next\n");
}
if state.should_previous {
state.should_previous = false;
print("Previous\n");
}
}
}

Binary file not shown.

51
modules/jai-mpris/main.c Normal file
View File

@ -0,0 +1,51 @@
#include <stdio.h>
#include <systemd/sd-bus.h>
// 1. Handle Method Calls (Play, Pause, etc.)
static int handle_play_pause(sd_bus_message *m, void *userdata, sd_bus_error *ret_error) {
printf("Action: Play/Pause toggled!\n");
return sd_bus_reply_method_return(m, NULL);
}
// 2. Define the MPRIS Interface VTable
static const sd_bus_vtable mpris_vtable[] = {
SD_BUS_VTABLE_START(0),
// Methods
SD_BUS_METHOD("PlayPause", NULL, NULL, handle_play_pause, SD_BUS_VTABLE_UNPRIVILEGED),
// Properties (Metadata is complex, Status is a simple string)
SD_BUS_PROPERTY("PlaybackStatus", "s", NULL, offsetof(struct my_player, status), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_VTABLE_END
};
int main() {
sd_bus *bus = NULL;
int r;
// Connect to the user session bus
r = sd_bus_open_user(&bus);
if (r < 0) return fprintf(stderr, "Failed to connect to bus: %s\n", strerror(-r));
// Own the MPRIS name so menus find you
r = sd_bus_request_name(bus, "org.mpris.MediaPlayer2.MyCPlayer", 0);
if (r < 0) return fprintf(stderr, "Failed to acquire name: %s\n", strerror(-r));
// Register the object path and interfaces
r = sd_bus_add_object_vtable(bus, NULL,
"/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
mpris_vtable,
NULL);
printf("Player active. Press Ctrl+C to stop.\n");
for (;;) {
r = sd_bus_process(bus, NULL);
if (r < 0) break;
if (r > 0) continue;
r = sd_bus_wait(bus, (uint64_t) -1);
if (r < 0) break;
}
sd_bus_unref(bus);
return 0;
}

View File

@ -0,0 +1,110 @@
#import "Basic";
// Build the C library first (only .so — Jai links dynamically, which pulls in libsystemd automatically):
// cc -fPIC -shared -o linux/libmpris.so mpris.c -lsystemd
libmpris :: #library "linux/libmpris";
/* ── Types ──────────────────────────────────────────────────────────────── */
Mpris_Player :: *void;
// Callbacks must be #c_call. Pass your context pointer as userdata.
Mpris_Callback :: #type (userdata: *void) -> void #c_call;
Mpris_Seek_Callback :: #type (offset_us: s64, userdata: *void) -> void #c_call;
Mpris_Set_Position_Callback :: #type (track_id: *u8, position_us: s64, userdata: *void) -> void #c_call;
Mpris_Volume_Callback :: #type (volume: float64, userdata: *void) -> void #c_call;
/* ── Raw C bindings ─────────────────────────────────────────────────────── */
_mpris_player_create :: (player_name: *u8, identity: *u8) -> Mpris_Player #foreign libmpris "mpris_player_create";
_mpris_player_destroy :: (p: Mpris_Player) #foreign libmpris "mpris_player_destroy";
_mpris_set_playback_status :: (p: Mpris_Player, status: *u8) #foreign libmpris "mpris_set_playback_status";
_mpris_set_metadata :: (p: Mpris_Player, track_id: *u8, title: *u8, artist: *u8, album: *u8, length_us: s64) #foreign libmpris "mpris_set_metadata";
_mpris_set_position :: (p: Mpris_Player, position_us: s64) #foreign libmpris "mpris_set_position";
_mpris_set_volume :: (p: Mpris_Player, volume: float64) #foreign libmpris "mpris_set_volume";
_mpris_set_can_go_next :: (p: Mpris_Player, value: s32) #foreign libmpris "mpris_set_can_go_next";
_mpris_set_can_go_previous :: (p: Mpris_Player, value: s32) #foreign libmpris "mpris_set_can_go_previous";
_mpris_set_can_play :: (p: Mpris_Player, value: s32) #foreign libmpris "mpris_set_can_play";
_mpris_set_can_pause :: (p: Mpris_Player, value: s32) #foreign libmpris "mpris_set_can_pause";
_mpris_set_can_seek :: (p: Mpris_Player, value: s32) #foreign libmpris "mpris_set_can_seek";
_mpris_emit_seeked :: (p: Mpris_Player, position_us: s64) #foreign libmpris "mpris_emit_seeked";
_mpris_on_play :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_play";
_mpris_on_pause :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_pause";
_mpris_on_play_pause :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_play_pause";
_mpris_on_stop :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_stop";
_mpris_on_next :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_next";
_mpris_on_previous :: (p: Mpris_Player, cb: Mpris_Callback, userdata: *void) #foreign libmpris "mpris_on_previous";
_mpris_on_seek :: (p: Mpris_Player, cb: Mpris_Seek_Callback, userdata: *void) #foreign libmpris "mpris_on_seek";
_mpris_on_set_position :: (p: Mpris_Player, cb: Mpris_Set_Position_Callback, userdata: *void) #foreign libmpris "mpris_on_set_position";
_mpris_on_volume :: (p: Mpris_Player, cb: Mpris_Volume_Callback, userdata: *void) #foreign libmpris "mpris_on_volume";
_mpris_process :: (p: Mpris_Player) -> s32 #foreign libmpris "mpris_process";
/* ── Jai-friendly wrappers (handle string conversion) ───────────────────── */
// player_name: D-Bus name component, no spaces (e.g. "MyPlayer")
// identity: Human-readable name shown in media menus (e.g. "My Music Player")
mpris_player_create :: (player_name: string, identity: string) -> Mpris_Player {
n := temp_c_string(player_name);
i := temp_c_string(identity);
return _mpris_player_create(n, i);
}
mpris_player_destroy :: (p: Mpris_Player) {
_mpris_player_destroy(p);
}
// status: "Playing" | "Paused" | "Stopped"
mpris_set_playback_status :: (p: Mpris_Player, status: string) {
_mpris_set_playback_status(p, temp_c_string(status));
}
Mpris_Metadata :: struct {
track_id : string; // D-Bus object path, e.g. "/myapp/track/42". Leave empty for auto.
title : string;
artist : string;
album : string;
length_us : s64; // Duration in microseconds. 0 = unknown.
}
mpris_set_metadata :: (p: Mpris_Player, meta: Mpris_Metadata) {
tid := ifx meta.track_id then temp_c_string(meta.track_id) else null;
t := ifx meta.title then temp_c_string(meta.title) else null;
a := ifx meta.artist then temp_c_string(meta.artist) else null;
al := ifx meta.album then temp_c_string(meta.album) else null;
_mpris_set_metadata(p, tid, t, a, al, meta.length_us);
}
mpris_set_position :: (p: Mpris_Player, position_us: s64) {
_mpris_set_position(p, position_us);
}
mpris_set_volume :: (p: Mpris_Player, volume: float64) {
_mpris_set_volume(p, volume);
}
mpris_set_can_go_next :: (p: Mpris_Player, v: bool) { _mpris_set_can_go_next(p, cast(s32) ifx v then 1 else 0); }
mpris_set_can_go_previous :: (p: Mpris_Player, v: bool) { _mpris_set_can_go_previous(p, cast(s32) ifx v then 1 else 0); }
mpris_set_can_play :: (p: Mpris_Player, v: bool) { _mpris_set_can_play(p, cast(s32) ifx v then 1 else 0); }
mpris_set_can_pause :: (p: Mpris_Player, v: bool) { _mpris_set_can_pause(p, cast(s32) ifx v then 1 else 0); }
mpris_set_can_seek :: (p: Mpris_Player, v: bool) { _mpris_set_can_seek(p, cast(s32) ifx v then 1 else 0); }
mpris_emit_seeked :: (p: Mpris_Player, position_us: s64) {
_mpris_emit_seeked(p, position_us);
}
mpris_on_play :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_play(p, cb, ud); }
mpris_on_pause :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_pause(p, cb, ud); }
mpris_on_play_pause :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_play_pause(p, cb, ud); }
mpris_on_stop :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_stop(p, cb, ud); }
mpris_on_next :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_next(p, cb, ud); }
mpris_on_previous :: (p: Mpris_Player, cb: Mpris_Callback, ud: *void) { _mpris_on_previous(p, cb, ud); }
mpris_on_seek :: (p: Mpris_Player, cb: Mpris_Seek_Callback, ud: *void) { _mpris_on_seek(p, cb, ud); }
mpris_on_set_position :: (p: Mpris_Player, cb: Mpris_Set_Position_Callback, ud: *void) { _mpris_on_set_position(p, cb, ud); }
mpris_on_volume :: (p: Mpris_Player, cb: Mpris_Volume_Callback, ud: *void) { _mpris_on_volume(p, cb, ud); }
// Returns true if messages were processed, false if idle, exits on error.
mpris_process :: (p: Mpris_Player) -> bool {
r := _mpris_process(p);
if r < 0 { log_error("mpris_process failed: %", r); return false; }
return r > 0;
}

409
modules/jai-mpris/mpris.c Normal file
View File

@ -0,0 +1,409 @@
#include "mpris.h"
#include <systemd/sd-bus.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MPRIS_PATH "/org/mpris/MediaPlayer2"
#define IFACE_ROOT "org.mpris.MediaPlayer2"
#define IFACE_PLAYER "org.mpris.MediaPlayer2.Player"
struct MprisPlayer {
sd_bus *bus;
sd_bus_slot *slot_root;
sd_bus_slot *slot_player;
char *identity;
char *playback_status;
double volume;
int64_t position_us;
int can_go_next;
int can_go_previous;
int can_play;
int can_pause;
int can_seek;
char *track_id;
char *title;
char *artist;
char *album;
int64_t length_us;
MprisCallback on_play; void *ud_play;
MprisCallback on_pause; void *ud_pause;
MprisCallback on_play_pause; void *ud_play_pause;
MprisCallback on_stop; void *ud_stop;
MprisCallback on_next; void *ud_next;
MprisCallback on_previous; void *ud_previous;
MprisSeekCallback on_seek; void *ud_seek;
MprisSetPositionCallback on_set_position; void *ud_set_position;
MprisVolumeCallback on_volume; void *ud_volume;
};
/* ── Root interface ─────────────────────────────────────────────────────── */
static int handle_raise(sd_bus_message *m, void *ud, sd_bus_error *e) {
return sd_bus_reply_method_return(m, "");
}
static int handle_quit(sd_bus_message *m, void *ud, sd_bus_error *e) {
return sd_bus_reply_method_return(m, "");
}
static int get_identity(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "s", ((MprisPlayer *)ud)->identity);
}
static int get_false(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", 0);
}
static int get_empty_strv(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
int r = sd_bus_message_open_container(reply, 'a', "s");
if (r < 0) return r;
return sd_bus_message_close_container(reply);
}
static const sd_bus_vtable root_vtable[] = {
SD_BUS_VTABLE_START(0),
SD_BUS_METHOD("Raise", "", "", handle_raise, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Quit", "", "", handle_quit, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_PROPERTY("CanQuit", "b", get_false, 0, 0),
SD_BUS_PROPERTY("CanRaise", "b", get_false, 0, 0),
SD_BUS_PROPERTY("HasTrackList", "b", get_false, 0, 0),
SD_BUS_PROPERTY("Identity", "s", get_identity, 0, 0),
SD_BUS_PROPERTY("SupportedUriSchemes", "as", get_empty_strv, 0, 0),
SD_BUS_PROPERTY("SupportedMimeTypes", "as", get_empty_strv, 0, 0),
SD_BUS_VTABLE_END
};
/* ── Player interface ───────────────────────────────────────────────────── */
static int handle_play(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_play) p->on_play(p->ud_play);
return sd_bus_reply_method_return(m, "");
}
static int handle_pause(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_pause) p->on_pause(p->ud_pause);
return sd_bus_reply_method_return(m, "");
}
static int handle_play_pause(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_play_pause) p->on_play_pause(p->ud_play_pause);
return sd_bus_reply_method_return(m, "");
}
static int handle_stop(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_stop) p->on_stop(p->ud_stop);
return sd_bus_reply_method_return(m, "");
}
static int handle_next(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_next) p->on_next(p->ud_next);
return sd_bus_reply_method_return(m, "");
}
static int handle_previous(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
if (p->on_previous) p->on_previous(p->ud_previous);
return sd_bus_reply_method_return(m, "");
}
static int handle_seek(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
int64_t offset;
int r = sd_bus_message_read(m, "x", &offset);
if (r < 0) return r;
if (p->on_seek) p->on_seek(offset, p->ud_seek);
return sd_bus_reply_method_return(m, "");
}
static int handle_set_position(sd_bus_message *m, void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
const char *track_id;
int64_t position;
int r = sd_bus_message_read(m, "ox", &track_id, &position);
if (r < 0) return r;
if (p->on_set_position) p->on_set_position(track_id, position, p->ud_set_position);
return sd_bus_reply_method_return(m, "");
}
static int handle_open_uri(sd_bus_message *m, void *ud, sd_bus_error *e) {
return sd_bus_reply_method_return(m, "");
}
static int get_playback_status(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "s", ((MprisPlayer *)ud)->playback_status);
}
static int get_loop_status(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "s", "None");
}
static int get_shuffle(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", 0);
}
static int get_metadata(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
int r;
r = sd_bus_message_open_container(reply, 'a', "{sv}");
if (r < 0) return r;
/* mpris:trackid is mandatory */
const char *trackid = p->track_id ? p->track_id : "/org/mpris/MediaPlayer2/TrackList/NoTrack";
r = sd_bus_message_open_container(reply, 'e', "sv"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", "mpris:trackid"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'v', "o"); if (r < 0) return r;
r = sd_bus_message_append(reply, "o", trackid); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
if (p->title) {
r = sd_bus_message_open_container(reply, 'e', "sv"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", "xesam:title"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'v', "s"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", p->title); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
}
/* xesam:artist is an array of strings */
if (p->artist) {
r = sd_bus_message_open_container(reply, 'e', "sv"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", "xesam:artist"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'v', "as"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'a', "s"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", p->artist); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
}
if (p->album) {
r = sd_bus_message_open_container(reply, 'e', "sv"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", "xesam:album"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'v', "s"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", p->album); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
}
if (p->length_us > 0) {
r = sd_bus_message_open_container(reply, 'e', "sv"); if (r < 0) return r;
r = sd_bus_message_append(reply, "s", "mpris:length"); if (r < 0) return r;
r = sd_bus_message_open_container(reply, 'v', "x"); if (r < 0) return r;
r = sd_bus_message_append(reply, "x", p->length_us); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
r = sd_bus_message_close_container(reply); if (r < 0) return r;
}
return sd_bus_message_close_container(reply);
}
static int get_volume(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "d", ((MprisPlayer *)ud)->volume);
}
static int set_volume(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *value,
void *ud, sd_bus_error *e) {
MprisPlayer *p = ud;
double v;
int r = sd_bus_message_read(value, "d", &v);
if (r < 0) return r;
p->volume = v;
if (p->on_volume) p->on_volume(v, p->ud_volume);
return 1; /* signal that the value changed so PropertiesChanged is emitted */
}
static int get_position(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "x", ((MprisPlayer *)ud)->position_us);
}
static int get_rate(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "d", 1.0);
}
static int get_can_go_next(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", ((MprisPlayer *)ud)->can_go_next);
}
static int get_can_go_previous(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", ((MprisPlayer *)ud)->can_go_previous);
}
static int get_can_play(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", ((MprisPlayer *)ud)->can_play);
}
static int get_can_pause(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", ((MprisPlayer *)ud)->can_pause);
}
static int get_can_seek(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", ((MprisPlayer *)ud)->can_seek);
}
static int get_can_control(sd_bus *bus, const char *path, const char *iface,
const char *prop, sd_bus_message *reply,
void *ud, sd_bus_error *e) {
return sd_bus_message_append(reply, "b", 1);
}
static const sd_bus_vtable player_vtable[] = {
SD_BUS_VTABLE_START(0),
SD_BUS_METHOD("Play", "", "", handle_play, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Pause", "", "", handle_pause, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("PlayPause", "", "", handle_play_pause, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Stop", "", "", handle_stop, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Next", "", "", handle_next, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Previous", "", "", handle_previous, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Seek", "x", "", handle_seek, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("SetPosition", "ox", "", handle_set_position, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("OpenUri", "s", "", handle_open_uri, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_SIGNAL("Seeked", "x", 0),
SD_BUS_PROPERTY ("PlaybackStatus", "s", get_playback_status, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("LoopStatus", "s", get_loop_status, 0, 0),
SD_BUS_PROPERTY ("Shuffle", "b", get_shuffle, 0, 0),
SD_BUS_PROPERTY ("Metadata", "a{sv}", get_metadata, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_WRITABLE_PROPERTY("Volume", "d", get_volume, set_volume, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("Position", "x", get_position, 0, SD_BUS_VTABLE_PROPERTY_EMITS_INVALIDATION),
SD_BUS_PROPERTY ("Rate", "d", get_rate, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("MinimumRate", "d", get_rate, 0, 0),
SD_BUS_PROPERTY ("MaximumRate", "d", get_rate, 0, 0),
SD_BUS_PROPERTY ("CanGoNext", "b", get_can_go_next, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("CanGoPrevious", "b", get_can_go_previous, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("CanPlay", "b", get_can_play, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("CanPause", "b", get_can_pause, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("CanSeek", "b", get_can_seek, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY ("CanControl", "b", get_can_control, 0, 0),
SD_BUS_VTABLE_END
};
/* ── Public API ─────────────────────────────────────────────────────────── */
MprisPlayer *mpris_player_create(const char *player_name, const char *identity) {
MprisPlayer *p = calloc(1, sizeof(MprisPlayer));
if (!p) return NULL;
p->identity = strdup(identity);
p->playback_status = strdup("Stopped");
p->volume = 1.0;
p->can_play = 1;
p->can_pause = 1;
int r;
r = sd_bus_open_user(&p->bus);
if (r < 0) { fprintf(stderr, "mpris: D-Bus connect failed: %s\n", strerror(-r)); goto fail; }
char bus_name[256];
snprintf(bus_name, sizeof(bus_name), "org.mpris.MediaPlayer2.%s", player_name);
r = sd_bus_request_name(p->bus, bus_name, 0);
if (r < 0) { fprintf(stderr, "mpris: could not acquire %s: %s\n", bus_name, strerror(-r)); goto fail; }
r = sd_bus_add_object_vtable(p->bus, &p->slot_root,
MPRIS_PATH, IFACE_ROOT, root_vtable, p);
if (r < 0) { fprintf(stderr, "mpris: register root interface failed: %s\n", strerror(-r)); goto fail; }
r = sd_bus_add_object_vtable(p->bus, &p->slot_player,
MPRIS_PATH, IFACE_PLAYER, player_vtable, p);
if (r < 0) { fprintf(stderr, "mpris: register player interface failed: %s\n", strerror(-r)); goto fail; }
return p;
fail:
mpris_player_destroy(p);
return NULL;
}
void mpris_player_destroy(MprisPlayer *p) {
if (!p) return;
sd_bus_slot_unref(p->slot_player);
sd_bus_slot_unref(p->slot_root);
sd_bus_unref(p->bus);
free(p->identity);
free(p->playback_status);
free(p->track_id);
free(p->title);
free(p->artist);
free(p->album);
free(p);
}
#define EMIT_PLAYER(p, ...) \
sd_bus_emit_properties_changed((p)->bus, MPRIS_PATH, IFACE_PLAYER, __VA_ARGS__, NULL)
void mpris_set_playback_status(MprisPlayer *p, const char *status) {
free(p->playback_status);
p->playback_status = strdup(status);
EMIT_PLAYER(p, "PlaybackStatus");
}
void mpris_set_metadata(MprisPlayer *p, const char *track_id, const char *title,
const char *artist, const char *album, int64_t length_us) {
free(p->track_id); p->track_id = track_id ? strdup(track_id) : NULL;
free(p->title); p->title = title ? strdup(title) : NULL;
free(p->artist); p->artist = artist ? strdup(artist) : NULL;
free(p->album); p->album = album ? strdup(album) : NULL;
p->length_us = length_us;
EMIT_PLAYER(p, "Metadata");
}
void mpris_set_volume(MprisPlayer *p, double volume) {
p->volume = volume;
EMIT_PLAYER(p, "Volume");
}
void mpris_set_position(MprisPlayer *p, int64_t position_us) {
p->position_us = position_us;
/* Position uses EMITS_INVALIDATION — clients poll it or watch the Seeked signal */
}
void mpris_set_can_go_next(MprisPlayer *p, int v) { p->can_go_next = v; EMIT_PLAYER(p, "CanGoNext"); }
void mpris_set_can_go_previous(MprisPlayer *p, int v) { p->can_go_previous = v; EMIT_PLAYER(p, "CanGoPrevious"); }
void mpris_set_can_play(MprisPlayer *p, int v) { p->can_play = v; EMIT_PLAYER(p, "CanPlay"); }
void mpris_set_can_pause(MprisPlayer *p, int v) { p->can_pause = v; EMIT_PLAYER(p, "CanPause"); }
void mpris_set_can_seek(MprisPlayer *p, int v) { p->can_seek = v; EMIT_PLAYER(p, "CanSeek"); }
void mpris_emit_seeked(MprisPlayer *p, int64_t position_us) {
p->position_us = position_us;
sd_bus_emit_signal(p->bus, MPRIS_PATH, IFACE_PLAYER, "Seeked", "x", position_us);
}
void mpris_on_play (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_play = cb; p->ud_play = ud; }
void mpris_on_pause (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_pause = cb; p->ud_pause = ud; }
void mpris_on_play_pause (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_play_pause = cb; p->ud_play_pause = ud; }
void mpris_on_stop (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_stop = cb; p->ud_stop = ud; }
void mpris_on_next (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_next = cb; p->ud_next = ud; }
void mpris_on_previous (MprisPlayer *p, MprisCallback cb, void *ud) { p->on_previous = cb; p->ud_previous = ud; }
void mpris_on_seek (MprisPlayer *p, MprisSeekCallback cb, void *ud) { p->on_seek = cb; p->ud_seek = ud; }
void mpris_on_set_position(MprisPlayer *p, MprisSetPositionCallback cb, void *ud) { p->on_set_position = cb; p->ud_set_position = ud; }
void mpris_on_volume (MprisPlayer *p, MprisVolumeCallback cb, void *ud) { p->on_volume = cb; p->ud_volume = ud; }
int mpris_process(MprisPlayer *p) {
return sd_bus_process(p->bus, NULL);
}

54
modules/jai-mpris/mpris.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
#include <stdint.h>
typedef struct MprisPlayer MprisPlayer;
typedef void (*MprisCallback) (void *userdata);
typedef void (*MprisSeekCallback) (int64_t offset_us, void *userdata);
typedef void (*MprisSetPositionCallback) (const char *track_id, int64_t position_us, void *userdata);
typedef void (*MprisVolumeCallback) (double volume, void *userdata);
// Creates the player and acquires "org.mpris.MediaPlayer2.<player_name>" on the session bus.
// identity is the human-readable name shown in media menus.
// Returns NULL on failure.
MprisPlayer *mpris_player_create(const char *player_name, const char *identity);
void mpris_player_destroy(MprisPlayer *p);
// Update state (each setter emits PropertiesChanged so media menus update immediately).
// status must be one of: "Playing", "Paused", "Stopped"
void mpris_set_playback_status(MprisPlayer *p, const char *status);
// Pass NULL for any field you don't have. length_us is track duration in microseconds.
void mpris_set_metadata(MprisPlayer *p, const char *track_id, const char *title,
const char *artist, const char *album, int64_t length_us);
// position_us is the current playback position; not broadcast automatically,
// only reported when polled. Call mpris_emit_seeked() after an actual seek.
void mpris_set_position(MprisPlayer *p, int64_t position_us);
void mpris_set_volume (MprisPlayer *p, double volume);
void mpris_set_can_go_next (MprisPlayer *p, int value);
void mpris_set_can_go_previous(MprisPlayer *p, int value);
void mpris_set_can_play (MprisPlayer *p, int value);
void mpris_set_can_pause (MprisPlayer *p, int value);
void mpris_set_can_seek (MprisPlayer *p, int value);
// Emit the Seeked signal after a seek completes.
void mpris_emit_seeked(MprisPlayer *p, int64_t position_us);
// Register callbacks for incoming control commands.
// userdata is passed through unchanged to your callback.
void mpris_on_play (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_pause (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_play_pause (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_stop (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_next (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_previous (MprisPlayer *p, MprisCallback cb, void *userdata);
void mpris_on_seek (MprisPlayer *p, MprisSeekCallback cb, void *userdata);
void mpris_on_set_position(MprisPlayer *p, MprisSetPositionCallback cb, void *userdata);
// Called when a remote client changes the Volume property.
void mpris_on_volume (MprisPlayer *p, MprisVolumeCallback cb, void *userdata);
// Drive the D-Bus event loop. Call this every frame / in your event loop.
// Returns >0 if messages were processed, 0 if idle, <0 on error.
int mpris_process(MprisPlayer *p);

BIN
modules/jai-mpris/mpris.o Normal file

Binary file not shown.

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
//
// Real spectrum analysis driving the visualizer.
//
// Since we now play OGG_COMPRESSED (Sound_Player has no public PCM output),
// we maintain a separate stb_vorbis decoder in app.analysis_vorbis. Each
// frame we seek it to the current play_cursor position and decode FFT_SIZE
// samples for the FFT. Forward seeks (normal playback) are near-instant since
// they just continue from the current bitstream position.
// We read directly from the same s16 PCM buffer Sound_Player is mixing from
// (sd.samples), centered on play_cursor. No second decoder, no seeking, no
// drift between bars and audio.
//
// play_cursor is in "virtual 44100 Hz sample" units (Sound_Player default);
// we convert to OGG sample position using the vorbis file's actual rate.
// Sound_Player advances play_cursor at sd.sampling_rate * current_rate per
// second, in *frame* units (not interleaved samples). To pull FFT_SIZE
// frames from sd.samples (which is interleaved), index by
// frame_index * nchannels.
//
MIN_VISUAL_FREQ :: 30.0;
@ -21,49 +21,30 @@ update_audio_analysis :: () {
return;
}
if !app.analysis_vorbis {
decay_spectrum(0.10);
return;
}
vorbis := cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis;
info := Stb_Vorbis.stb_vorbis_get_info(vorbis);
if info.sample_rate == 0 || info.channels == 0 {
decay_spectrum(0.10);
return;
}
// Convert play_cursor to real elapsed seconds, then to OGG sample position.
// play_cursor advances at play_rate * current_rate per second, where
// current_rate = play_rate / ogg_rate (set by make_stream). So:
// time_s = play_cursor / (play_rate * current_rate)
// ogg_pos = time_s * ogg_rate
sd := app.current_stream.sound_data;
play_rate := cast(float) sd.sampling_rate; // 44100 default for OGG_COMPRESSED
ogg_rate := cast(float) info.sample_rate; // actual OGG sample rate
current_rate := app.current_stream.current_rate;
if current_rate <= 0 { decay_spectrum(0.10); return; }
time_s := app.current_stream.play_cursor / (play_rate * current_rate);
ogg_frame := cast(s64)(time_s * ogg_rate);
seek_frame := max(0, ogg_frame - FFT_SIZE / 2);
if sd.type != .LINEAR_SAMPLE_ARRAY || !sd.samples || sd.sampling_rate == 0 || sd.nchannels == 0 {
decay_spectrum(0.10);
return;
}
Stb_Vorbis.stb_vorbis_seek_frame(vorbis, cast(u32) seek_frame);
nch := cast(s64) sd.nchannels;
total_frames := sd.nsamples_times_nchannels / nch;
nch := cast(s32) min(info.channels, 2);
decoded := Stb_Vorbis.stb_vorbis_get_samples_short_interleaved(
vorbis, nch, analysis_pcm_buf.data, FFT_SIZE * nch);
if decoded < FFT_SIZE / 4 {
cursor_frame := cast(s64) app.current_stream.play_cursor;
start_frame := cursor_frame - FFT_SIZE / 2;
if start_frame < 0 start_frame = 0;
if start_frame + FFT_SIZE > total_frames start_frame = total_frames - FFT_SIZE;
if start_frame < 0 {
decay_spectrum(0.10);
return;
}
// Mix to mono and apply Hann window.
inv_chan := 1.0 / cast(float) nch;
base := start_frame * nch;
for k: 0..FFT_SIZE-1 {
if k >= decoded { fft_re[k] = 0; fft_im[k] = 0; continue; }
sum: float = 0;
for ch: 0..nch-1 sum += cast(float) analysis_pcm_buf[k * nch + ch];
for ch: 0..nch-1 sum += cast(float) sd.samples[base + k * nch + ch];
mono := sum * inv_chan / 32768.0;
fft_re[k] = mono * fft_window[k];
fft_im[k] = 0;
@ -71,7 +52,7 @@ update_audio_analysis :: () {
fft();
rate := ogg_rate;
rate := cast(float) sd.sampling_rate;
nyquist := rate * 0.5;
log_lo := log(MIN_VISUAL_FREQ);
log_hi := log(nyquist);
@ -105,8 +86,6 @@ update_audio_analysis :: () {
#scope_file
analysis_pcm_buf: [FFT_SIZE * 2] s16;
decay_spectrum :: (rate: float) {
for 0..SPECTRUM_BINS-1 {
app.spectrum[it] = lerp(app.spectrum[it], 0, rate);

View File

@ -1,110 +1,78 @@
//
// 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.
// OGG Vorbis decoder. Jellyfin always serves us /universal as OGG, so this
// is the single decode path. Returns Sound_Data with type LINEAR_SAMPLE_ARRAY
// — same buffer the visualizer reads via sound_data.samples (no parallel
// decoder, no cursor desync).
//
// 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.
// We do NOT use stb_vorbis_decode_memory: that malloc()s the output buffer
// with libc's allocator, and Sound_Player's release_asset path frees it
// with Jai's allocator → SEGV at song end. Instead we open the stream,
// allocate the PCM buffer ourselves, and decode into it. Same allocator on
// both ends.
//
Audio_Format :: enum {
UNKNOWN;
OGG;
WAV;
MP3;
FLAC;
}
decode_ogg :: (bytes: string, name: string) -> Sound.Sound_Data, bool {
err: s32;
v := Stb_Vorbis.stb_vorbis_open_memory(bytes.data, cast(s32) bytes.count, *err, null);
if !v {
log_error("decode_ogg: open failed for '%' (err=%)", name, err);
return .{}, false;
}
defer Stb_Vorbis.stb_vorbis_close(v);
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;
info := Stb_Vorbis.stb_vorbis_get_info(v);
nch := cast(s64) info.channels;
if nch <= 0 || info.sample_rate == 0 {
log_error("decode_ogg: bad info for '%' (ch=%, rate=%)", name, info.channels, info.sample_rate);
return .{}, false;
}
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);
total_frames := cast(s64) Stb_Vorbis.stb_vorbis_stream_length_in_samples(v);
if total_frames <= 0 {
log_error("decode_ogg: zero-length stream for '%'", name);
return .{}, false;
}
total_samples := total_frames * nch;
samples := cast(*s16) alloc(total_samples * size_of(s16));
if !samples {
log_error("decode_audio: decoder failed for '%'", name);
return .{}, format, false;
log_error("decode_ogg: alloc failed for '%'", name);
return .{}, 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;
// stb_vorbis sometimes hands back fewer frames than the header advertised
// (chained / corrupt streams), so loop until it returns 0 and use what
// we got.
decoded_frames: s64 = 0;
while decoded_frames < total_frames {
remaining_shorts := cast(s32) ((total_frames - decoded_frames) * nch);
n := Stb_Vorbis.stb_vorbis_get_samples_short_interleaved(
v, cast(s32) nch,
samples + decoded_frames * nch,
remaining_shorts);
if n <= 0 break;
decoded_frames += n;
}
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);
if decoded_frames == 0 {
log_error("decode_ogg: no frames decoded for '%'", name);
free(samples);
return .{}, false;
}
return .{}, format, false;
actual_samples := decoded_frames * nch;
sd: Sound.Sound_Data;
sd.name = copy_string(name);
sd.loaded = true;
sd.type = .LINEAR_SAMPLE_ARRAY;
sd.nchannels = cast(u16) nch;
sd.sampling_rate = cast(u32) info.sample_rate;
sd.nsamples_times_nchannels = actual_samples;
sd.samples = samples;
sd.buffer.count = actual_samples * size_of(s16);
sd.buffer.data = cast(*u8) samples;
log_info("decode_ogg: '%' ch=%, rate=%, frames=%", name, nch, info.sample_rate, decoded_frames);
return sd, true;
}

View File

@ -3,3 +3,4 @@
#load "queue.jai";
#load "fft.jai";
#load "analysis.jai";
#load "media_controls.jai";

View File

@ -0,0 +1,154 @@
//
// Platform media controls integration.
//
// Generic API used throughout the codebase. Platform backends live below:
// Linux — MPRIS2 via D-Bus (jai-mpris)
// macOS — stub (add MPNowPlayingInfoCenter bindings here when needed)
//
// Call sites use media_controls_* unconditionally; the procs no-op on
// platforms with no backend yet.
//
media_controls_init :: () {
#if OS == .LINUX _mc_linux_init();
}
media_controls_shutdown :: () {
#if OS == .LINUX _mc_linux_shutdown();
}
// Call once per frame from the main loop; dispatches pending D-Bus messages
// and acts on any remote commands (next/prev/play-pause/seek).
media_controls_pump :: () {
#if OS == .LINUX _mc_linux_pump();
}
// Call after a new track starts playing.
media_controls_notify_track :: () {
#if OS == .LINUX _mc_linux_notify_track();
}
// Call after pause/unpause state changes.
media_controls_notify_status :: () {
#if OS == .LINUX _mc_linux_notify_status();
}
// ── Linux / MPRIS2 ───────────────────────────────────────────────────────────
#if OS == .LINUX {
Mpris :: #import "jai-mpris";
#scope_file
_mpris: Mpris.Mpris_Player;
// Flags set by #c_call callbacks, read + cleared in _mc_linux_pump().
_want_play_pause : bool;
_want_next : bool;
_want_prev : bool;
_want_stop : bool;
_seek_offset_us : s64;
_seek_pending : bool;
_set_pos_us : s64;
_set_pos_pending : bool;
_cb_play_pause :: (ud: *void) #c_call { _want_play_pause = true; }
_cb_next :: (ud: *void) #c_call { _want_next = true; }
_cb_prev :: (ud: *void) #c_call { _want_prev = true; }
_cb_stop :: (ud: *void) #c_call { _want_stop = true; }
_cb_seek :: (offset_us: s64, ud: *void) #c_call {
_seek_offset_us = offset_us;
_seek_pending = true;
}
_cb_set_position :: (track_id: *u8, pos_us: s64, ud: *void) #c_call {
_set_pos_us = pos_us;
_set_pos_pending = true;
}
_mc_linux_init :: () {
_mpris = Mpris.mpris_player_create("CelicaPlayer", "Celica");
if !_mpris {
log_warn("media_controls: MPRIS registration failed");
return;
}
Mpris.mpris_on_play_pause (_mpris, _cb_play_pause, null);
Mpris.mpris_on_play (_mpris, _cb_play_pause, null);
Mpris.mpris_on_pause (_mpris, _cb_play_pause, null);
Mpris.mpris_on_next (_mpris, _cb_next, null);
Mpris.mpris_on_previous (_mpris, _cb_prev, null);
Mpris.mpris_on_stop (_mpris, _cb_stop, null);
Mpris.mpris_on_seek (_mpris, _cb_seek, null);
Mpris.mpris_on_set_position(_mpris, _cb_set_position, null);
Mpris.mpris_set_can_play (_mpris, true);
Mpris.mpris_set_can_pause (_mpris, true);
Mpris.mpris_set_can_go_next (_mpris, true);
Mpris.mpris_set_can_go_previous(_mpris, true);
Mpris.mpris_set_can_seek (_mpris, true);
Mpris.mpris_set_playback_status(_mpris, "Stopped");
log_info("media_controls: MPRIS registered as org.mpris.MediaPlayer2.CelicaPlayer");
}
_mc_linux_shutdown :: () {
if !_mpris return;
Mpris.mpris_player_destroy(_mpris);
_mpris = null;
}
_mc_linux_pump :: () {
if !_mpris return;
Mpris.mpris_process(_mpris);
if _want_play_pause {
_want_play_pause = false;
audio_toggle_pause();
_mc_linux_notify_status();
}
if _want_next { _want_next = false; queue_next(); }
if _want_prev { _want_prev = false; queue_prev(); }
if _want_stop {
_want_stop = false;
stop_current_stream();
Mpris.mpris_set_playback_status(_mpris, "Stopped");
}
if _seek_pending {
_seek_pending = false;
if app.current_stream && app.current_stream.sound_data {
rate := cast(float64) app.current_stream.sound_data.sampling_rate;
cur_us := cast(s64)(app.current_stream.play_cursor / rate * 1_000_000.0);
audio_seek_seconds(cast(float)(cur_us + _seek_offset_us) / 1_000_000.0);
}
}
if _set_pos_pending {
_set_pos_pending = false;
audio_seek_seconds(cast(float) _set_pos_us / 1_000_000.0);
}
// Keep MPRIS playhead in sync every frame.
if app.current_stream && app.current_stream.sound_data {
rate := cast(float64) app.current_stream.sound_data.sampling_rate;
pos_us := cast(s64)(app.current_stream.play_cursor / rate * 1_000_000.0);
Mpris.mpris_set_position(_mpris, pos_us);
}
}
_mc_linux_notify_track :: () {
if !_mpris return;
meta: Mpris.Mpris_Metadata;
meta.title = app.current_track.name;
meta.artist = app.current_track.artist;
meta.album = app.current_track.album;
// Jellyfin duration_ticks are 100-ns intervals; MPRIS wants microseconds.
meta.length_us = app.current_track.duration_ticks / 10;
Mpris.mpris_set_metadata(_mpris, meta);
Mpris.mpris_set_playback_status(_mpris, "Playing");
}
_mc_linux_notify_status :: () {
if !_mpris return;
Mpris.mpris_set_playback_status(_mpris, ifx app.paused then "Paused" else "Playing");
}
} // #if OS == .LINUX

View File

@ -1,12 +1,13 @@
//
// Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes
// everything server-side) and hands it to Sound_Player's native OGG path.
// everything server-side), decodes locally to s16 PCM via stb_vorbis, and
// hands the LINEAR_SAMPLE_ARRAY to Sound_Player. The visualizer reads from
// the same sd.samples buffer — single source of truth, no parallel decoder,
// no cursor desync.
//
// OGG_COMPRESSED streams from a decoder rather than decoding the full PCM
// into memory, so there is no large heap buffer to manage — Sound_Player
// owns the decoder lifecycle. The only memory we own is the heap-allocated
// Sound_Data struct and the raw OGG bytes (stored in sound_data.buffer);
// both are freed in sound_release_callback when Sound_Player is done.
// We own the Sound_Data struct, the stb_vorbis-malloc'd PCM buffer (held in
// sd.samples / sd.buffer), and the Track copy. All freed in
// sound_release_callback when Sound_Player is finished with the stream.
//
audio_play_track :: (track: Track) {
@ -25,7 +26,7 @@ audio_play_track :: (track: Track) {
if output_hz <= 0 output_hz = 44100;
path := tprint(
"/Audio/%/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&audioSampleRate=%&audioChannels=2&userId=%&deviceId=%&api_key=%",
track.id, output_hz, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token,
track.id, output_hz, app.jellyfin.user_id, app.jellyfin.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);
@ -44,6 +45,7 @@ audio_toggle_pause :: () {
s.desired_rate = 1;
s.inaudible = false;
}
media_controls_notify_status();
}
audio_is_paused :: () -> bool {
@ -64,15 +66,6 @@ stop_current_stream :: () {
app.current_stream = null;
}
analysis_close :: () {
if app.analysis_vorbis {
Stb_Vorbis.stb_vorbis_close(cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis);
app.analysis_vorbis = null;
}
free(app.analysis_ogg);
app.analysis_ogg = "";
}
// Called by Sound_Player when it is finished with a stream (natural end or
// stop_stream_abruptly). We own the Sound_Data struct and the OGG bytes in
// sound_data.buffer; free both here.
@ -122,36 +115,19 @@ on_track_downloaded :: (task: *Http_Task) {
return;
}
// Transfer ownership of the bytes to Sound_Data.buffer.
bytes := task.response.body;
task.response.body = "";
defer free(bytes); // stb_vorbis_decode_memory copies what it needs
sd := Sound.load_audio_data(pending.name, bytes);
if !sd.loaded {
log_error("audio: OGG decode failed for '%'", pending.name);
free(bytes);
return;
}
sd, ok := decode_ogg(bytes, pending.name);
if !ok return;
data := New(Sound.Sound_Data);
data.* = sd;
// Open a separate OGG decoder for the visualizer FFT before we transfer
// the bytes to Sound_Player (bytes is still valid at this point).
analysis_close();
app.analysis_ogg = copy_string(bytes); // independent copy — Sound_Player owns `bytes`
{
err: s32;
v := Stb_Vorbis.stb_vorbis_open_memory(
app.analysis_ogg.data, cast(s32) app.analysis_ogg.count, *err, null);
if v app.analysis_vorbis = v;
else log_warn("audio: analysis decoder open failed (err=%)", err);
}
stop_current_stream();
free_track(*app.current_track);
app.current_track = clone_track(pending.*);
app.current_format = .OGG;
app.paused = false;
app.track_entity_id += 1;
@ -161,6 +137,7 @@ on_track_downloaded :: (task: *Http_Task) {
app.current_stream = stream;
app.track_finished = false;
log_info("audio: playing '% — %' [OGG] artist_id=%",
media_controls_notify_track();
log_info("audio: playing '% — %' artist_id=%",
app.current_track.artist, app.current_track.name, app.current_track.artist_id);
}

View File

@ -38,7 +38,6 @@ App :: struct {
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
@ -46,14 +45,11 @@ App :: struct {
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.
// Per-frame audio analysis used by the visualizer shader. Reads
// current_stream.sound_data.samples around play_cursor — same buffer
// Sound_Player is mixing from, so the bars stay perfectly in sync.
spectrum: [SPECTRUM_BINS] float;
// Separate OGG decoder kept open for the visualizer FFT. Sound_Player's
// OGG_COMPRESSED type gives no PCM access, so we maintain our own handle.
analysis_vorbis: *void; // *Stb_Vorbis.stb_vorbis, void to avoid header dep
analysis_ogg: string; // copy of OGG bytes kept alive for the decoder
// Cover art palette — extracted once per album, used for theming.
palette: [4] Vector4; // [0]=bg [1]=highlight [2]=mid [3]=accent
palette_ready: bool;
@ -70,6 +66,12 @@ app: App;
app_init :: () {
log_info("player starting up");
// Random seed feeds device_id generation on first run; left unseeded,
// every install would mint the same id and the first-token-wins rule
// (one active token per DeviceId) would chain-revoke.
ns, _ := to_nanoseconds(current_time_monotonic());
random_seed(cast(u64) ns);
setup_data_directory();
app.window = create_window(app.window_width, app.window_height, "Celica");
@ -96,13 +98,20 @@ app_init :: () {
app.jellyfin.username = copy_string("");
app.jellyfin.password = copy_string("");
if config_load() {
app.current_view = .LIBRARY;
library_refresh_artists();
loaded := config_load();
ensure_device_id();
media_controls_init();
if loaded {
// Don't trust the saved token on faith — the server may have
// revoked it. Probe /System/Info; on_validate_session decides
// whether to land in LIBRARY or LOGIN.
jellyfin_validate_session_async();
}
}
app_shutdown :: () {
media_controls_shutdown();
if app.audio_inited Sound.sound_player_shutdown();
jellyfin_client_shutdown(*app.jellyfin);
log_info("bye");

View File

@ -5,6 +5,9 @@
// We persist:
// - server URL / username / auth token / user id (so subsequent launches
// skip the login screen entirely)
// - device_id: a stable per-install id. Jellyfin permits only one active
// token per DeviceId, so re-using a hardcoded constant means any second
// instance silently revokes the first instance's saved token.
// - master volume (so the user doesn't have to re-set it every time)
//
// Password is never persisted.
@ -15,6 +18,7 @@ Persisted_Config :: struct {
username: string;
auth_token: string;
user_id: string;
device_id: string;
master_volume: float = 1.0;
}
@ -37,6 +41,7 @@ config_save :: () {
cfg.username = app.jellyfin.username;
cfg.auth_token = app.jellyfin.auth_token;
cfg.user_id = app.jellyfin.user_id;
cfg.device_id = app.jellyfin.device_id;
cfg.master_volume = app.master_volume;
json := Jaison.json_write_string(cfg);
@ -69,6 +74,7 @@ config_load :: () -> bool {
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);
app.jellyfin.device_id = copy_string(cfg.device_id);
if cfg.master_volume > 0 app.master_volume = clamp(cfg.master_volume, 0, 1);
@ -80,8 +86,28 @@ config_load :: () -> bool {
return false;
}
// Ensure we have a stable device_id. Called after config_load so first-run
// installs (or pre-device_id configs migrating in) get one and persist it.
ensure_device_id :: () {
if app.jellyfin.device_id.count > 0 return;
app.jellyfin.device_id = generate_device_id();
log_info("auth: generated new device_id");
config_save();
}
config_clear :: () {
p := config_path();
if !p return;
file_delete(p);
}
#scope_file
generate_device_id :: () -> string {
builder: String_Builder;
for 0..15 {
b := random_get() & 0xff;
print_to_builder(*builder, "%", formatInt(b, base=16, minimum_digits=2));
}
return builder_to_string(*builder);
}

View File

@ -14,6 +14,7 @@
#import "Window_Creation";
#import "GetRect_LeftHanded";
#import "GL";
Simp :: #import "Simp";
Input :: #import "Input";
@ -21,6 +22,5 @@ Sound :: #import "Sound_Player";
Jaison :: #import "Jaison";
Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true);
Audio_Decoders :: #import "audio_decoders";
Stb_Image :: #import "stb_image";
Stb_Vorbis :: #import "stb_vorbis";

View File

@ -26,6 +26,8 @@ run_main_loop :: () {
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
jellyfin_quick_connect_pump();// drives /QuickConnect/Connect polling at QC_POLL_INTERVAL_S cadence
media_controls_pump(); // dispatch D-Bus / OS media key events
if app.audio_inited Sound.set_master_volume(app.master_volume);

View File

@ -1,7 +1,8 @@
//
// 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.
// bass-driven background pulse, plus a particle system that shoots sparks
// from bar tips and arcs them under gravity.
//
// 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
@ -71,7 +72,7 @@ gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
}
// Modulate brightness by amplitude; dim quiet bars a little.
bright := 0.45 + 0.55 * v;
body = .{c.x * bright, c.y * bright, c.z * bright, 0.65 + 0.35 * v};
body = .{c.x * bright, c.y * bright, c.z * bright, 0.85 + 0.15 * v};
} else {
body = neon_color(hue, v, t);
}
@ -91,6 +92,18 @@ gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
if bar_h > cap_h * 2 {
Simp.immediate_quad(x0, cy - bar_h - cap_h, x1, cy - bar_h, tip);
}
// Spawn sparks from the top bar tip when the bar is energetic.
// Spawn x is random across the full bar width.
if v > 0.18 {
spawn_chance := (v - 0.18) / 0.82 * 0.28;
if random_get_zero_to_one() < spawn_chance {
spark_col := tip;
spark_col.w = 1.0;
spawn_x := x0 + random_get_zero_to_one() * bar_w;
particle_spawn(spawn_x, cy - bar_h, spark_col, min(w, h));
}
}
}
// Center mirror line — a thin neon glow across the middle.
@ -100,10 +113,81 @@ gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
line_col.w = 0.5 + 0.3 * bass;
Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col);
}
particles_update_draw(w, h);
}
#scope_file
// ── Spark particle system ─────────────────────────────────────────────────────
MAX_PARTICLES :: 800;
Particle :: struct {
x, y: float;
vx, vy: float;
life: float; // remaining seconds
max_life: float;
color: Vector4;
size: float;
}
particles: [MAX_PARTICLES] Particle;
live_count: int;
particle_spawn :: (x: float, y: float, color: Vector4, ref_size: float) {
if live_count >= MAX_PARTICLES return;
p := *particles[live_count];
p.x = x;
p.y = y;
// Mostly upward: angle biased toward -PI/2 (up in screen coords), ±50° spread.
base_angle := -1.5708; // -PI/2
angle := base_angle + (random_get_zero_to_one() - 0.5) * 1.396; // ±40°
speed := 150.0 + random_get_zero_to_one() * 200.0;
p.vx = cos(angle) * speed;
p.vy = sin(angle) * speed;
p.max_life = 2.0 + random_get_zero_to_one() * 2.0;
p.life = p.max_life;
p.color = color;
// 0.3 0.8% of the smaller screen dimension.
p.size = ref_size * (0.003 + random_get_zero_to_one() * 0.005);
live_count += 1;
}
particles_update_draw :: (w: float, h: float) {
dt := app.dt;
i := 0;
while i < live_count {
p := *particles[i];
p.life -= dt;
if p.life <= 0 {
particles[i] = particles[live_count - 1];
live_count -= 1;
continue;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
// Elastic bounce off all four walls.
if p.x < 0 { p.x = -p.x; p.vx = -p.vx; }
if p.x > w { p.x = 2*w - p.x; p.vx = -p.vx; }
if p.y < 0 { p.y = -p.y; p.vy = -p.vy; }
if p.y > h { p.y = 2*h - p.y; p.vy = -p.vy; }
// Linear fade so they stay vivid most of their life.
frac := p.life / p.max_life;
col := p.color;
col.w = frac * 0.5;
half := p.size * 0.5;
Simp.immediate_quad(p.x - half, p.y - half, p.x + half, p.y + half, col);
i += 1;
}
}
// ── Colour helpers ────────────────────────────────────────────────────────────
neon_color :: (hue: float, intensity: float, t: float) -> Vector4 {
// Slow hue drift over time so the color palette evolves even on
// sustained tones.

View File

@ -137,7 +137,7 @@ perform_curl_blocking :: (task: *Http_Task) -> Http_Response {
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(tprint("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"));

View File

@ -3,6 +3,10 @@
//
// Async via http_submit; on_login_response runs on the main thread.
//
// We also have a startup-time session probe (jellyfin_validate_session_async)
// that hits /System/Info with the saved token. On 401 we drop to the login
// screen instead of leaving the user staring at an empty library.
//
Login_User :: struct {
Id: string;
@ -31,6 +35,29 @@ jellyfin_logout :: (c: *Jellyfin_Client) {
config_clear();
}
// Token may have been revoked (re-login from another DeviceId match,
// password change, admin revoke). Clear the token but keep server_url,
// username, and the persisted device_id so the user can re-auth in one
// click — and so we don't burn a new device_id, which would orphan all
// prior session entries on the server.
jellyfin_force_logout :: () {
log_warn("auth: clearing session (token rejected)");
free(app.jellyfin.auth_token);
free(app.jellyfin.user_id);
app.jellyfin.auth_token = "";
app.jellyfin.user_id = "";
app.jellyfin.logged_in = false;
config_save();
app.current_view = .LOGIN;
}
// Hit a cheap authenticated endpoint with the saved token to check whether
// the server still honors it. /System/Info requires auth and is light.
jellyfin_validate_session_async :: () {
log_info("auth: validating saved session");
http_submit("GET", "/System/Info", on_done=on_validate_session);
}
#scope_file
on_login_response :: (task: *Http_Task) {
@ -55,3 +82,21 @@ on_login_response :: (task: *Http_Task) {
app.current_view = .LIBRARY;
library_refresh_artists();
}
on_validate_session :: (task: *Http_Task) {
if task.response.status_code == 401 {
jellyfin_force_logout();
return;
}
if !task.response.ok {
// Network blip, server down, etc. — don't nuke the saved token over
// a transient failure; keep the user on the login screen so they
// can retry, but leave the credentials in place.
log_error("auth: validate failed status=% (keeping saved token)", task.response.status_code);
app.current_view = .LOGIN;
return;
}
log_info("auth: saved session ok");
app.current_view = .LIBRARY;
library_refresh_artists();
}

View File

@ -2,15 +2,23 @@
// 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).
// We follow Jellyfin's auth scheme: every request carries an
// `Authorization: MediaBrowser ...` header. Authenticated requests append a
// Token field; the login request omits it.
//
// Note: the legacy `X-Emby-Authorization` header is being removed in
// Jellyfin 12.0 and admins on 10.11+ can already disable it. Use the modern
// `Authorization` header.
//
// The DeviceId is per-install and persisted in config.json. Jellyfin
// permits only one active access token per DeviceId — sharing one across
// installs (or hardcoding a constant) silently revokes prior tokens the
// next time anyone logs in.
//
CLIENT_NAME :: "player";
CLIENT_NAME :: "Jellyfin Celica Music Player";
CLIENT_VERSION :: "0.0.1";
DEVICE_NAME :: "player";
DEVICE_ID :: "player-dev-device"; // TODO: persist a real id per install
DEVICE_NAME :: "Celica";
Jellyfin_Client :: struct {
server_url: string;
@ -19,8 +27,23 @@ Jellyfin_Client :: struct {
auth_token: string;
user_id: string;
device_id: string; // persisted; one per install (see config.jai)
logged_in: bool;
login_pending: bool;
// Quick Connect — see quick_connect.jai. Lives on the client so the
// login view can render its current state.
qc_state: Quick_Connect_State;
qc_code: string; // 6-char user-facing code shown in the UI
qc_secret: string; // server token we poll with
qc_poll_at: float64; // app.current_time when next poll is due
}
Quick_Connect_State :: enum {
IDLE;
INITIATING;
WAITING;
AUTHENTICATING;
}
jellyfin_client_init :: (c: *Jellyfin_Client) {
@ -45,11 +68,11 @@ 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);
CLIENT_NAME, DEVICE_NAME, c.device_id, CLIENT_VERSION, c.auth_token);
}
return tprint(
"MediaBrowser Client=\"%\", Device=\"%\", DeviceId=\"%\", Version=\"%\"",
CLIENT_NAME, DEVICE_NAME, DEVICE_ID, CLIENT_VERSION);
CLIENT_NAME, DEVICE_NAME, c.device_id, CLIENT_VERSION);
}
http_get :: (c: *Jellyfin_Client, path: string) -> Http_Response {
@ -95,7 +118,7 @@ http_request :: (c: *Jellyfin_Client, method: string, path: string, body: string
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(tprint("Authorization: %", auth)));
headers = curl_slist_append(headers, temp_c_string("Accept: application/json"));
if method == "POST" || method == "PUT" {

View File

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

View File

@ -153,6 +153,7 @@ library_select_album :: (album_id: string) {
on_artists_loaded :: (task: *Http_Task) {
app.library.artists_loading = false;
if task.response.status_code == 401 { jellyfin_force_logout(); return; }
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;
@ -182,6 +183,7 @@ on_albums_loaded :: (task: *Http_Task) {
if gen != app.library.albums_request_gen return; // user moved on; discard
app.library.albums_loading = false;
if task.response.status_code == 401 { jellyfin_force_logout(); return; }
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;
@ -212,6 +214,7 @@ on_tracks_loaded :: (task: *Http_Task) {
if gen != app.library.tracks_request_gen return;
app.library.tracks_loading = false;
if task.response.status_code == 401 { jellyfin_force_logout(); return; }
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;

View File

@ -0,0 +1,136 @@
//
// Quick Connect login. The user opens any other authenticated Jellyfin
// client (web UI, mobile app, etc.), enters the 6-character code we
// display, and the server hands us an AccessToken without ever seeing the
// password — so we don't have to store one.
//
// Flow:
// 1. POST /QuickConnect/Initiate → { Code, Secret }
// 2. show Code, poll GET /QuickConnect/Connect?secret=… every 3s
// until { Authenticated: true }
// 3. POST /Users/AuthenticateWithQuickConnect with { Secret }
// → standard AuthenticationResult { AccessToken, User }
//
// The poll cadence runs off the main-loop pump (jellyfin_quick_connect_pump)
// so we don't burn a thread sleeping between polls. State lives on
// Jellyfin_Client; the login view reads it to draw either the form or the
// "enter this code" panel.
//
QC_POLL_INTERVAL_S :: 3.0;
// Field names map to the Jellyfin response via JsonName notes — `Code` is
// a reserved primitive in Jai, so we rename to UserCode and instruct
// Jaison to look for "Code" in the JSON.
Quick_Connect_Result :: struct {
Authenticated: bool;
Secret: string;
UserCode: string; @JsonName(Code)
}
jellyfin_quick_connect_start :: () {
c := *app.jellyfin;
if c.qc_state != .IDLE return;
qc_reset(c);
c.qc_state = .INITIATING;
log_info("qc: initiating");
http_submit("POST", "/QuickConnect/Initiate", on_done=on_qc_initiated);
}
jellyfin_quick_connect_cancel :: () {
c := *app.jellyfin;
if c.qc_state == .IDLE return;
log_info("qc: cancelled");
qc_reset(c);
}
// Called from the main loop. Fires the next /QuickConnect/Connect poll once
// the cooldown has elapsed.
jellyfin_quick_connect_pump :: () {
c := *app.jellyfin;
if c.qc_state != .WAITING return;
if app.current_time < c.qc_poll_at return;
c.qc_poll_at = app.current_time + QC_POLL_INTERVAL_S;
path := tprint("/QuickConnect/Connect?secret=%", c.qc_secret);
http_submit("GET", path, on_done=on_qc_polled);
}
#scope_file
qc_reset :: (c: *Jellyfin_Client) {
free(c.qc_code);
free(c.qc_secret);
c.qc_code = "";
c.qc_secret = "";
c.qc_poll_at = 0;
c.qc_state = .IDLE;
}
on_qc_initiated :: (task: *Http_Task) {
c := *app.jellyfin;
if c.qc_state != .INITIATING return;
if !task.response.ok {
log_error("qc: initiate failed status=% body=%", task.response.status_code,
slice(task.response.body, 0, min(300, task.response.body.count)));
qc_reset(c);
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Quick_Connect_Result);
if !ok || !parsed.UserCode || !parsed.Secret {
log_error("qc: initiate parse failed");
qc_reset(c);
return;
}
c.qc_code = copy_string(parsed.UserCode);
c.qc_secret = copy_string(parsed.Secret);
c.qc_state = .WAITING;
c.qc_poll_at = app.current_time + QC_POLL_INTERVAL_S;
log_info("qc: code=%", c.qc_code);
}
on_qc_polled :: (task: *Http_Task) {
c := *app.jellyfin;
if c.qc_state != .WAITING return; // user cancelled while in flight
if !task.response.ok {
// 404 is normal once the server has cleaned up an expired request;
// anything else we treat as a transient error and keep polling.
log_warn("qc: poll status=%", task.response.status_code);
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Quick_Connect_Result);
if !ok return;
if !parsed.Authenticated return;
log_info("qc: approved, exchanging secret for token");
c.qc_state = .AUTHENTICATING;
body := tprint("{\"Secret\":\"%\"}", c.qc_secret);
http_submit("POST", "/Users/AuthenticateWithQuickConnect", body, on_done=on_qc_authenticated);
}
on_qc_authenticated :: (task: *Http_Task) {
c := *app.jellyfin;
if c.qc_state != .AUTHENTICATING return;
if !task.response.ok {
log_error("qc: authenticate failed status=%", task.response.status_code);
qc_reset(c);
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Login_Response);
if !ok || !parsed.AccessToken {
log_error("qc: authenticate parse failed");
qc_reset(c);
return;
}
free(c.auth_token); free(c.user_id);
c.auth_token = copy_string(parsed.AccessToken);
c.user_id = copy_string(parsed.User.Id);
c.logged_in = true;
log_info("auth: logged in via Quick Connect as % (id=%)", parsed.User.Name, c.user_id);
qc_reset(c);
config_save();
app.current_view = .LIBRARY;
library_refresh_artists();
}

View File

@ -217,7 +217,7 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) {
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)
then tprint("% — %", app.current_track.artist, app.current_track.name)
else "—";
label(get_rect(x + h * 0.3, y + h * 0.2, w * 0.40, h * 0.6), title, *label_theme);

View File

@ -1,6 +1,9 @@
//
// Login screen. Three text inputs (server URL, username, password) and a
// big chunky CONNECT button.
// Login screen. Two paths:
// 1. Password — server URL + username + password + CONNECT
// 2. Quick Connect — server URL + QUICK CONNECT, then enter the code
// shown here in another authenticated Jellyfin client. No password
// stored on disk.
//
draw_login_view :: () {
@ -26,6 +29,11 @@ draw_login_view :: () {
label(r, "a jellyfin music player", *label_theme);
}
if app.jellyfin.qc_state != .IDLE {
draw_quick_connect_panel(w, h, k);
return;
}
// Form column.
field_w := min(w * 0.5, 12.0 * k);
field_x := (w - field_w) * 0.5;
@ -60,6 +68,52 @@ draw_login_view :: () {
jellyfin_login_async(*app.jellyfin);
}
}
// QUICK CONNECT button — secondary action, smaller and below.
cursor_y += field_h * 1.2 + k * 0.3;
qc_button_theme := button_theme;
qc_button_theme.label_theme.text_color = .{0.7, 0.7, 0.9, 1};
if button(get_rect(field_x, cursor_y, field_w, field_h), "QUICK CONNECT", *qc_button_theme) {
jellyfin_quick_connect_start();
}
}
draw_quick_connect_panel :: (w: float, h: float, k: float) {
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;
instructions := "Open another Jellyfin client and enter this code:";
if app.jellyfin.qc_state == .INITIATING instructions = "Contacting server...";
if app.jellyfin.qc_state == .AUTHENTICATING instructions = "Approved — signing in...";
{
label_theme := app.theme.label_theme;
label_theme.font = app.body_font;
label_theme.alignment = .Center;
label_theme.text_color = .{0.85, 0.85, 0.95, 1};
r := get_rect(0, h * 0.36, w, app.body_font.character_height * 1.5);
label(r, instructions, *label_theme);
}
{
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.45, w, app.title_font.character_height * 1.4);
code := ifx app.jellyfin.qc_code then app.jellyfin.qc_code else "------";
label(r, code, *label_theme);
}
cancel_y := h * 0.65;
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
button_theme.label_theme.text_color = .{0.7, 0.7, 0.9, 1};
if button(get_rect(field_x, cancel_y, field_w, field_h), "CANCEL", *button_theme) {
jellyfin_quick_connect_cancel();
}
}
//