improvements
This commit is contained in:
parent
19dc85821f
commit
7c694705db
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
27
build.jai
27
build.jai
@ -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";
|
||||
|
||||
BIN
build/player
BIN
build/player
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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";
|
||||
@ -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"
|
||||
@ -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
BIN
modules/jai-mpris/example/main
Executable file
Binary file not shown.
63
modules/jai-mpris/example/main.jai
Normal file
63
modules/jai-mpris/example/main.jai
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
modules/jai-mpris/linux/libmpris.so
Executable file
BIN
modules/jai-mpris/linux/libmpris.so
Executable file
Binary file not shown.
51
modules/jai-mpris/main.c
Normal file
51
modules/jai-mpris/main.c
Normal 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;
|
||||
}
|
||||
110
modules/jai-mpris/module.jai
Normal file
110
modules/jai-mpris/module.jai
Normal 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
409
modules/jai-mpris/mpris.c
Normal 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
54
modules/jai-mpris/mpris.h
Normal 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
BIN
modules/jai-mpris/mpris.o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
Binary file not shown.
@ -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;
|
||||
}
|
||||
|
||||
@ -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
Binary file not shown.
Binary file not shown.
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
detect_audio_format :: (bytes: string) -> Audio_Format {
|
||||
if bytes.count < 4 return .UNKNOWN;
|
||||
|
||||
b := bytes.data;
|
||||
// MP3: ID3 tag
|
||||
if b[0] == #char "I" && b[1] == #char "D" && b[2] == #char "3" return .MP3;
|
||||
// MP3: frame sync 0xFFEx (also 0xFFFx for MPEG-1, 0xFFEx for MPEG-2)
|
||||
if b[0] == 0xFF && (b[1] & 0xE0) == 0xE0 return .MP3;
|
||||
// FLAC: "fLaC"
|
||||
if b[0] == #char "f" && b[1] == #char "L" && b[2] == #char "a" && b[3] == #char "C" return .FLAC;
|
||||
// OGG: "OggS"
|
||||
if b[0] == #char "O" && b[1] == #char "g" && b[2] == #char "g" && b[3] == #char "S" return .OGG;
|
||||
// WAV: "RIFF"
|
||||
if b[0] == #char "R" && b[1] == #char "I" && b[2] == #char "F" && b[3] == #char "F" return .WAV;
|
||||
|
||||
return .UNKNOWN;
|
||||
}
|
||||
|
||||
//
|
||||
// Decode `bytes` into a Sound_Data ready to feed to Sound.make_stream().
|
||||
//
|
||||
// Ownership notes:
|
||||
// - On the OGG/WAV path, Sound_Player keeps `bytes` alive via Sound_Data.buffer.
|
||||
// - On the MP3/FLAC path, we call decoder_free on the original bytes immediately
|
||||
// (we have decoded to a separate s16 PCM buffer); the Sound_Data references
|
||||
// the dr_libs-malloc'd PCM buffer instead.
|
||||
//
|
||||
// TODO: free Sound_Data + its buffer in the release_asset path. Today we leak.
|
||||
//
|
||||
decode_audio :: (bytes: string, name: string) -> Sound.Sound_Data, Audio_Format, bool {
|
||||
format := detect_audio_format(bytes);
|
||||
log_info("decode_audio: '%' bytes=% format=%", name, bytes.count, format);
|
||||
|
||||
if format == .OGG || format == .WAV {
|
||||
result := Sound.load_audio_data(name, bytes);
|
||||
return result, format, result.loaded;
|
||||
}
|
||||
|
||||
if format == .MP3 || format == .FLAC {
|
||||
channels: u32;
|
||||
sample_rate: u32;
|
||||
total_frames: u64;
|
||||
|
||||
samples: *s16;
|
||||
if format == .MP3 {
|
||||
samples = Audio_Decoders.decode_mp3(
|
||||
bytes.data, cast(u64) bytes.count,
|
||||
*channels, *sample_rate, *total_frames);
|
||||
} else {
|
||||
samples = Audio_Decoders.decode_flac(
|
||||
bytes.data, cast(u64) bytes.count,
|
||||
*channels, *sample_rate, *total_frames);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
#load "queue.jai";
|
||||
#load "fft.jai";
|
||||
#load "analysis.jai";
|
||||
#load "media_controls.jai";
|
||||
|
||||
154
src/audio/media_controls.jai
Normal file
154
src/audio/media_controls.jai
Normal 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
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
136
src/jellyfin/quick_connect.jai
Normal file
136
src/jellyfin/quick_connect.jai
Normal 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();
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user