This commit is contained in:
Tuomas Katajisto 2026-05-14 11:16:05 +03:00
parent 875a42c244
commit 5dd7e6c0df
11 changed files with 205 additions and 75 deletions

View File

@ -33,6 +33,7 @@ build :: () {
// installed (no -dev package), we fall back to a symlink in ./lib/. // installed (no -dev package), we fall back to a symlink in ./lib/.
extra_linker: [..] string; extra_linker: [..] string;
array_add(*extra_linker, tprint("-L%lib", #filepath)); array_add(*extra_linker, tprint("-L%lib", #filepath));
array_add(*extra_linker, "-ldr_flac");
options.additional_linker_arguments = extra_linker; options.additional_linker_arguments = extra_linker;
set_build_options(options, w); set_build_options(options, w);

Binary file not shown.

BIN
lib/libdr_flac.a Normal file

Binary file not shown.

View File

@ -1,6 +1,6 @@
// //
// OGG Vorbis decoder. Jellyfin always serves us /universal as OGG, so this // Audio decoders. Primary path: OGG Vorbis (stb_vorbis). Fallback: WAV PCM.
// is the single decode path. Returns Sound_Data with type LINEAR_SAMPLE_ARRAY // Both return Sound_Data with type LINEAR_SAMPLE_ARRAY
// — same buffer the visualizer reads via sound_data.samples (no parallel // — same buffer the visualizer reads via sound_data.samples (no parallel
// decoder, no cursor desync). // decoder, no cursor desync).
// //
@ -76,3 +76,44 @@ decode_ogg :: (bytes: string, name: string) -> Sound.Sound_Data, bool {
log_info("decode_ogg: '%' ch=%, rate=%, frames=%", name, nch, info.sample_rate, decoded_frames); log_info("decode_ogg: '%' ch=%, rate=%, frames=%", name, nch, info.sample_rate, decoded_frames);
return sd, true; return sd, true;
} }
#scope_file
drflac_open_memory_and_read_pcm_frames_s16 :: (data: *void, dataSize: u64, channels: *u32, sampleRate: *u32, totalFrames: *u64, pAlloc: *void) -> *s16 #foreign;
drflac_free :: (p: *void, pAlloc: *void) #foreign;
#scope_export
decode_flac :: (bytes: string, name: string) -> Sound.Sound_Data, bool {
channels: u32;
sample_rate: u32;
total_frames: u64;
// dr_flac allocates the PCM buffer with malloc; copy into Jai alloc so
// sound_release_callback's free() doesn't cross allocator boundaries.
raw := drflac_open_memory_and_read_pcm_frames_s16(
bytes.data, cast(u64) bytes.count, *channels, *sample_rate, *total_frames, null);
if !raw {
log_error("decode_flac: failed for '%'", name);
return .{}, false;
}
defer drflac_free(raw, null);
total_samples := cast(s64) total_frames * cast(s64) channels;
samples := cast(*s16) alloc(total_samples * size_of(s16));
if !samples return .{}, false;
memcpy(samples, raw, cast(s64) (total_samples * size_of(s16)));
sd: Sound.Sound_Data;
sd.name = copy_string(name);
sd.loaded = true;
sd.type = .LINEAR_SAMPLE_ARRAY;
sd.nchannels = cast(u16) channels;
sd.sampling_rate = sample_rate;
sd.nsamples_times_nchannels = total_samples;
sd.samples = samples;
sd.buffer.count = total_samples * size_of(s16);
sd.buffer.data = cast(*u8) samples;
log_info("decode_flac: '%' ch=%, rate=%, frames=%", name, channels, sample_rate, total_frames);
return sd, true;
}

View File

@ -1,27 +1,24 @@
// //
// Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes // Audio playback. Primary path: OGG Vorbis transcode via Jellyfin.
// everything server-side), decodes locally to s16 PCM via stb_vorbis, and // Fallback: if Jellyfin returns a 0-byte body (broken server-side transcoder),
// hands the LINEAR_SAMPLE_ARRAY to Sound_Player. The visualizer reads from // retry as a direct static stream and auto-detect the format (FLAC / OGG / WAV).
// the same sd.samples buffer — single source of truth, no parallel decoder,
// no cursor desync.
//
// 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.
// //
Track_Download_Task :: struct {
track: Track;
is_fallback: bool;
}
audio_play_track :: (track: Track) { audio_play_track :: (track: Track) {
if !app.audio_inited return; if !app.audio_inited return;
pending := New(Track); task := New(Track_Download_Task);
pending.* = clone_track(track); task.track = clone_track(track);
task.is_fallback = false;
// Ask Jellyfin to transcode everything to OGG Vorbis 192 kbps. // Ask Jellyfin to transcode to OGG Vorbis 192 kbps.
// We pin audioSampleRate to Sound_Player's actual output device rate so // Pin audioSampleRate to the device rate so Sound_Player's desired_rate
// the OGG file's sample rate matches what Sound_Player expects when // calculation (sampling_rate / output_rate) stays at 1.0.
// calculating desired_rate (= sound_data.sampling_rate / output_rate).
// Without this, Jellyfin defaults to 48000 Hz on many systems while
// sound_data.sampling_rate stays at 44100 → plays at 91.9% speed.
output_hz := Sound.get_audio_sampling_rate(); output_hz := Sound.get_audio_sampling_rate();
if output_hz <= 0 output_hz = 44100; if output_hz <= 0 output_hz = 44100;
path := tprint( path := tprint(
@ -29,7 +26,7 @@ audio_play_track :: (track: Track) {
track.id, output_hz, app.jellyfin.user_id, app.jellyfin.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); log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks);
http_submit("GET", path, on_done=on_track_downloaded, user_data=pending); http_submit("GET", path, on_done=on_track_downloaded, user_data=task);
} }
audio_toggle_pause :: () { audio_toggle_pause :: () {
@ -106,20 +103,63 @@ free_track :: (t: *Track) {
#scope_file #scope_file
on_track_downloaded :: (task: *Http_Task) { on_track_downloaded :: (http: *Http_Task) {
pending := cast(*Track) task.user_data; dt := cast(*Track_Download_Task) http.user_data;
defer { free_track(pending); free(pending); } defer { free_track(*dt.track); free(dt); }
if !task.response.ok { if !http.response.ok {
log_error("audio download '%' failed: status=%", pending.name, task.response.status_code); log_error("audio download '%' failed: status=%", dt.track.name, http.response.status_code);
return; return;
} }
bytes := task.response.body; bytes := http.response.body;
task.response.body = ""; http.response.body = "";
defer free(bytes); // stb_vorbis_decode_memory copies what it needs
sd, ok := decode_ogg(bytes, pending.name); // Jellyfin returns 200 + 0 bytes when the server-side transcoder fails
// (e.g. misconfigured ffmpeg codec for a given container). Fall back to
// PCM WAV which requires no codec — just a demux/resample.
if bytes.count == 0 && !dt.is_fallback {
free(bytes);
log_warn("audio: OGG transcode empty for '%', retrying as direct stream", dt.track.name);
fallback := New(Track_Download_Task);
fallback.track = clone_track(dt.track);
fallback.is_fallback = true;
path := tprint("/Audio/%/stream?static=true&userId=%&api_key=%",
dt.track.id, app.jellyfin.user_id, app.jellyfin.auth_token);
http_submit("GET", path, on_done=on_track_downloaded, user_data=fallback);
return;
}
if bytes.count == 0 {
free(bytes);
log_error("audio: download empty for '%' even after direct-stream fallback", dt.track.name);
return;
}
sd: Sound.Sound_Data;
ok: bool;
if dt.is_fallback {
// Detect format by magic bytes and decode accordingly.
// FLAC and OGG decoders copy into their own buffers — free bytes after.
// WAV: Sound.load_audio_data keeps a reference into bytes, so do NOT free;
// sound_release_callback will free sd.buffer (= bytes) when playback ends.
is_flac := bytes.count >= 4 && bytes[0] == 0x66 && bytes[1] == 0x4C && bytes[2] == 0x61 && bytes[3] == 0x43;
is_wav := bytes.count >= 4 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46;
if is_flac {
sd, ok = decode_flac(bytes, dt.track.name);
free(bytes);
} else if is_wav {
sd = Sound.load_audio_data(dt.track.name, bytes);
ok = sd.loaded;
if !ok free(bytes);
} else {
sd, ok = decode_ogg(bytes, dt.track.name);
free(bytes);
}
} else {
sd, ok = decode_ogg(bytes, dt.track.name);
free(bytes);
}
if !ok return; if !ok return;
data := New(Sound.Sound_Data); data := New(Sound.Sound_Data);
@ -127,7 +167,7 @@ on_track_downloaded :: (task: *Http_Task) {
stop_current_stream(); stop_current_stream();
free_track(*app.current_track); free_track(*app.current_track);
app.current_track = clone_track(pending.*); app.current_track = clone_track(dt.track);
app.paused = false; app.paused = false;
app.track_entity_id += 1; app.track_entity_id += 1;
@ -139,6 +179,6 @@ on_track_downloaded :: (task: *Http_Task) {
app.track_finished = false; app.track_finished = false;
media_controls_notify_track(); media_controls_notify_track();
lyrics_request(app.current_track.id); lyrics_request(app.current_track.id);
log_info("audio: playing '% — %' artist_id=%", log_info("audio: playing '% — %' (% fallback=%)",
app.current_track.artist, app.current_track.name, app.current_track.artist_id); app.current_track.artist, app.current_track.name, app.current_track.artist_id, dt.is_fallback);
} }

View File

@ -106,14 +106,6 @@ gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
} }
} }
// Center mirror line — a thin neon glow across the middle.
{
line_h := 1.5;
line_col := ifx app.palette_ready then app.palette[1] else Vector4.{1.0, 0.4, 0.8, 1};
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); particles_update_draw(w, h);
} }

View File

@ -43,7 +43,7 @@ http_submit :: (
) -> *Http_Task { ) -> *Http_Task {
task := New(Http_Task); task := New(Http_Task);
task.method = copy_string(method); task.method = copy_string(method);
task.url = copy_string(tprint("%/%", app.jellyfin.server_url, trim_left(path, "/"))); task.url = copy_string(tprint("%/%", trim_right(app.jellyfin.server_url, "/"), trim_left(path, "/")));
task.body = copy_string(body); task.body = copy_string(body);
task.auth = copy_string(build_auth_header(*app.jellyfin)); task.auth = copy_string(build_auth_header(*app.jellyfin));
task.on_done = on_done; task.on_done = on_done;

View File

@ -109,7 +109,7 @@ http_request :: (c: *Jellyfin_Client, method: string, path: string, body: string
} }
defer curl_easy_cleanup(handle); defer curl_easy_cleanup(handle);
url := tprint("%/%", c.server_url, trim_left(path, "/")); url := tprint("%/%", trim_right(c.server_url, "/"), trim_left(path, "/"));
curl_easy_setopt(handle, CURLoption.URL, temp_c_string(url)); curl_easy_setopt(handle, CURLoption.URL, temp_c_string(url));
buf: Write_Buffer; buf: Write_Buffer;

View File

@ -10,8 +10,9 @@
// //
Artist :: struct { Artist :: struct {
id: string; id: string; // primary ID (for image requests)
name: string; all_ids: string; // comma-separated IDs for album queries (Jellyfin sometimes splits one artist across multiple entries)
name: string;
} }
Album :: struct { Album :: struct {
@ -99,12 +100,16 @@ Items_Response :: struct {
library_refresh_artists :: () { library_refresh_artists :: () {
if app.library.artists_loading return; if app.library.artists_loading return;
app.library.artists_loading = true; app.library.artists_loading = true;
path := tprint("/Users/%/Items?IncludeItemTypes=MusicArtist&Recursive=true&SortBy=SortName&Limit=2000", app.jellyfin.user_id); // /Artists/AlbumArtists returns artists derived from actual track tags,
// so names and IDs match what album/track queries expect.
// IncludeItemTypes=MusicArtist uses folder-derived entries that can have
// different names (e.g. AC_DC vs AC/DC) and non-matching IDs.
path := tprint("/Artists/AlbumArtists?userId=%&Recursive=true&SortBy=SortName&Limit=2000", app.jellyfin.user_id);
log_info("library: fetching artists"); log_info("library: fetching artists");
http_submit("GET", path, on_done=on_artists_loaded); http_submit("GET", path, on_done=on_artists_loaded);
} }
library_select_artist :: (artist_id: string) { library_select_artist :: (artist_id: string, all_artist_ids: string) {
if app.library.selected_artist_id == artist_id && app.library.albums_loaded return; if app.library.selected_artist_id == artist_id && app.library.albums_loaded return;
free(app.library.selected_artist_id); free(app.library.selected_artist_id);
@ -123,8 +128,8 @@ library_select_artist :: (artist_id: string) {
app.library.tracks_loading = false; app.library.tracks_loading = false;
app.library.tracks_scroll = 0; app.library.tracks_scroll = 0;
path := tprint("/Users/%/Items?IncludeItemTypes=MusicAlbum&ArtistIds=%&Recursive=true&SortBy=ProductionYear,SortName&Limit=2000", app.jellyfin.user_id, artist_id); path := tprint("/Users/%/Items?IncludeItemTypes=MusicAlbum&ArtistIds=%&Recursive=true&SortBy=ProductionYear,SortName&Limit=2000", app.jellyfin.user_id, all_artist_ids);
log_info("library: fetching albums for %", artist_id); log_info("library: fetching albums for % (ids=%)", artist_id, all_artist_ids);
http_submit("GET", path, on_done=on_albums_loaded, user_data=cast(*void) app.library.albums_request_gen); http_submit("GET", path, on_done=on_albums_loaded, user_data=cast(*void) app.library.albums_request_gen);
} }
@ -165,9 +170,25 @@ on_artists_loaded :: (task: *Http_Task) {
} }
free_artists(*app.library.artists); free_artists(*app.library.artists);
for parsed.Items { for parsed.Items {
item_id := it.Id;
item_name := it.Name;
// Merge into an existing entry with the same name; Jellyfin splits
// the same artist across multiple IDs when metadata tags differ.
merged := false;
for * app.library.artists {
if it.name == item_name {
old := it.all_ids;
it.all_ids = copy_string(tprint("%,%", old, item_id));
free(old);
merged = true;
break;
}
}
if merged continue;
a: Artist; a: Artist;
a.id = copy_string(it.Id); a.id = copy_string(item_id);
a.name = copy_string(it.Name); a.all_ids = copy_string(item_id);
a.name = copy_string(item_name);
array_add(*app.library.artists, a); array_add(*app.library.artists, a);
} }
app.library.artists_loaded = true; app.library.artists_loaded = true;
@ -245,7 +266,7 @@ on_tracks_loaded :: (task: *Http_Task) {
} }
free_artists :: (xs: *[..] Artist) { free_artists :: (xs: *[..] Artist) {
for xs.* { free(it.id); free(it.name); } for xs.* { free(it.id); free(it.all_ids); free(it.name); }
array_reset(xs); array_reset(xs);
} }

View File

@ -109,7 +109,7 @@ draw_artists_column :: (x: float, y: float, w: float, h: float) {
btn_x := s.x + thumb_size + thumb_pad; btn_x := s.x + thumb_size + thumb_pad;
btn_w := s.w - thumb_size - thumb_pad; btn_w := s.w - thumb_size - thumb_pad;
if button(get_rect(btn_x, s.y, btn_w, row_h), it.name, *bt, identifier=it_index) { if button(get_rect(btn_x, s.y, btn_w, row_h), it.name, *bt, identifier=it_index) {
library_select_artist(it.id); library_select_artist(it.id, it.all_ids);
} }
s.y += row_h * 1.05; s.y += row_h * 1.05;
} }

View File

@ -385,14 +385,19 @@ draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) {
cur_start := lines[active].start_ticks; cur_start := lines[active].start_ticks;
next_start := lines[active + 1].start_ticks; next_start := lines[active + 1].start_ticks;
if next_start > cur_start { if next_start > cur_start {
t := cast(float) (cur_ticks - cur_start) / cast(float) (next_start - cur_start); // Only scroll during a short window leading into the next line,
base += ease(t); // so the active line stays centered for most of its duration.
TRANSITION_TICKS :: 4_000_000; // 400 ms in 100-ns ticks
window_start := next_start - TRANSITION_TICKS;
if cur_ticks >= window_start {
t := cast(float) (cur_ticks - window_start) / cast(float) TRANSITION_TICKS;
base += ease(t);
}
} }
} }
active_font := app.row_font; font := app.body_font;
other_font := app.body_font; line_h := font.character_height * 1.65;
line_h := active_font.character_height * 1.75;
cx := x + w * 0.5; cx := x + w * 0.5;
cy := y + h * 0.5; cy := y + h * 0.5;
@ -408,12 +413,14 @@ draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) {
if line_y > y + h continue; if line_y > y + h continue;
d := rel; if d < 0 d = -d; d := rel; if d < 0 d = -d;
is_active := d < 0.5;
alpha := 1.1 - d * 0.45; // Lines within 1.5 of active are highlighted; those beyond fade out.
if alpha < 0.0 alpha = 0.0; alpha: float;
if alpha > 1.0 alpha = 1.0; if d <= 1.5 {
if is_active alpha = 1.0; alpha = 1.0;
} else {
alpha = max(0.0, 1.0 - (d - 1.5) * 0.5);
}
// Edge fade so lines don't pop in/out at the rect bounds. // Edge fade so lines don't pop in/out at the rect bounds.
top_room := line_y - y; top_room := line_y - y;
@ -423,11 +430,11 @@ draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) {
alpha *= edge; alpha *= edge;
if alpha < 0.04 continue; if alpha < 0.04 continue;
col := ifx is_active then col_active else col_other; col := ifx d <= 1.5 then col_active else col_other;
col.w = alpha; col.w = alpha;
lt := app.theme.label_theme; lt := app.theme.label_theme;
lt.font = ifx is_active then active_font else other_font; lt.font = font;
lt.alignment = .Center; lt.alignment = .Center;
lt.text_color = col; lt.text_color = col;
label(get_rect(x, line_y, w, line_h), lines[it_index].text, *lt); label(get_rect(x, line_y, w, line_h), lines[it_index].text, *lt);
@ -435,22 +442,50 @@ draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) {
} }
draw_static_lyrics :: (x: float, y: float, w: float, h: float) { draw_static_lyrics :: (x: float, y: float, w: float, h: float) {
line_h := app.body_font.character_height * 1.55; lines := app.lyrics.lines;
max_lines := cast(int) (h / line_h);
if max_lines <= 0 return;
n := min(max_lines, app.lyrics.lines.count); // Estimate current line from playback progress.
block_h := cast(float) n * line_h; base: float = 0;
sy := y + (h - block_h) * 0.5; if app.current_stream && app.current_stream.sound_data {
rate := cast(float) app.current_stream.sound_data.sampling_rate;
elapsed := cast(float) app.current_stream.play_cursor / rate;
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
if total > 0 base = elapsed / total * cast(float) (lines.count - 1);
}
font := app.body_font;
line_h := font.character_height * 1.65;
cx := x + w * 0.5;
cy := y + h * 0.5;
col_active := now_playing_title_color();
col_other := now_playing_album_color();
for lines {
rel := cast(float) it_index - base;
line_y := cy + rel * line_h - line_h * 0.5;
if line_y + line_h < y continue;
if line_y > y + h continue;
d := ifx rel < 0 then -rel else rel;
alpha: float = ifx d <= 1.5 then 1.0 else max(0.0, 1.0 - (d - 1.5) * 0.5);
top_room := line_y - y;
bot_room := (y + h) - (line_y + line_h);
edge := clamp(min(top_room, bot_room) / line_h + 0.5, 0, 1);
alpha *= edge;
if alpha < 0.04 continue;
col := ifx d <= 1.5 then col_active else col_other;
col.w = alpha;
col := now_playing_album_color();
for 0..n-1 {
lt := app.theme.label_theme; lt := app.theme.label_theme;
lt.font = app.body_font; lt.font = font;
lt.alignment = .Center; lt.alignment = .Center;
lt.text_color = col; lt.text_color = col;
label(get_rect(x, sy + cast(float) it * line_h, w, line_h), label(get_rect(x, line_y, w, line_h), lines[it_index].text, *lt);
app.lyrics.lines[it].text, *lt);
} }
} }