work
This commit is contained in:
parent
875a42c244
commit
5dd7e6c0df
@ -33,6 +33,7 @@ build :: () {
|
||||
// installed (no -dev package), we fall back to a symlink in ./lib/.
|
||||
extra_linker: [..] string;
|
||||
array_add(*extra_linker, tprint("-L%lib", #filepath));
|
||||
array_add(*extra_linker, "-ldr_flac");
|
||||
options.additional_linker_arguments = extra_linker;
|
||||
|
||||
set_build_options(options, w);
|
||||
|
||||
BIN
build/player
BIN
build/player
Binary file not shown.
BIN
lib/libdr_flac.a
Normal file
BIN
lib/libdr_flac.a
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
//
|
||||
// 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
|
||||
// Audio decoders. Primary path: OGG Vorbis (stb_vorbis). Fallback: WAV PCM.
|
||||
// Both return Sound_Data with type LINEAR_SAMPLE_ARRAY
|
||||
// — same buffer the visualizer reads via sound_data.samples (no parallel
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,24 @@
|
||||
//
|
||||
// Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes
|
||||
// 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.
|
||||
//
|
||||
// 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 playback. Primary path: OGG Vorbis transcode via Jellyfin.
|
||||
// Fallback: if Jellyfin returns a 0-byte body (broken server-side transcoder),
|
||||
// retry as a direct static stream and auto-detect the format (FLAC / OGG / WAV).
|
||||
//
|
||||
|
||||
Track_Download_Task :: struct {
|
||||
track: Track;
|
||||
is_fallback: bool;
|
||||
}
|
||||
|
||||
audio_play_track :: (track: Track) {
|
||||
if !app.audio_inited return;
|
||||
|
||||
pending := New(Track);
|
||||
pending.* = clone_track(track);
|
||||
task := New(Track_Download_Task);
|
||||
task.track = clone_track(track);
|
||||
task.is_fallback = false;
|
||||
|
||||
// Ask Jellyfin to transcode everything to OGG Vorbis 192 kbps.
|
||||
// We pin audioSampleRate to Sound_Player's actual output device rate so
|
||||
// the OGG file's sample rate matches what Sound_Player expects when
|
||||
// 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.
|
||||
// Ask Jellyfin to transcode to OGG Vorbis 192 kbps.
|
||||
// Pin audioSampleRate to the device rate so Sound_Player's desired_rate
|
||||
// calculation (sampling_rate / output_rate) stays at 1.0.
|
||||
output_hz := Sound.get_audio_sampling_rate();
|
||||
if output_hz <= 0 output_hz = 44100;
|
||||
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,
|
||||
);
|
||||
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 :: () {
|
||||
@ -106,20 +103,63 @@ free_track :: (t: *Track) {
|
||||
|
||||
#scope_file
|
||||
|
||||
on_track_downloaded :: (task: *Http_Task) {
|
||||
pending := cast(*Track) task.user_data;
|
||||
defer { free_track(pending); free(pending); }
|
||||
on_track_downloaded :: (http: *Http_Task) {
|
||||
dt := cast(*Track_Download_Task) http.user_data;
|
||||
defer { free_track(*dt.track); free(dt); }
|
||||
|
||||
if !task.response.ok {
|
||||
log_error("audio download '%' failed: status=%", pending.name, task.response.status_code);
|
||||
if !http.response.ok {
|
||||
log_error("audio download '%' failed: status=%", dt.track.name, http.response.status_code);
|
||||
return;
|
||||
}
|
||||
|
||||
bytes := task.response.body;
|
||||
task.response.body = "";
|
||||
defer free(bytes); // stb_vorbis_decode_memory copies what it needs
|
||||
bytes := http.response.body;
|
||||
http.response.body = "";
|
||||
|
||||
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;
|
||||
|
||||
data := New(Sound.Sound_Data);
|
||||
@ -127,7 +167,7 @@ on_track_downloaded :: (task: *Http_Task) {
|
||||
|
||||
stop_current_stream();
|
||||
free_track(*app.current_track);
|
||||
app.current_track = clone_track(pending.*);
|
||||
app.current_track = clone_track(dt.track);
|
||||
app.paused = false;
|
||||
|
||||
app.track_entity_id += 1;
|
||||
@ -139,6 +179,6 @@ on_track_downloaded :: (task: *Http_Task) {
|
||||
app.track_finished = false;
|
||||
media_controls_notify_track();
|
||||
lyrics_request(app.current_track.id);
|
||||
log_info("audio: playing '% — %' artist_id=%",
|
||||
app.current_track.artist, app.current_track.name, app.current_track.artist_id);
|
||||
log_info("audio: playing '% — %' (% fallback=%)",
|
||||
app.current_track.artist, app.current_track.name, app.current_track.artist_id, dt.is_fallback);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ http_submit :: (
|
||||
) -> *Http_Task {
|
||||
task := New(Http_Task);
|
||||
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.auth = copy_string(build_auth_header(*app.jellyfin));
|
||||
task.on_done = on_done;
|
||||
|
||||
@ -109,7 +109,7 @@ http_request :: (c: *Jellyfin_Client, method: string, path: string, body: string
|
||||
}
|
||||
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));
|
||||
|
||||
buf: Write_Buffer;
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
//
|
||||
|
||||
Artist :: struct {
|
||||
id: string;
|
||||
id: string; // primary ID (for image requests)
|
||||
all_ids: string; // comma-separated IDs for album queries (Jellyfin sometimes splits one artist across multiple entries)
|
||||
name: string;
|
||||
}
|
||||
|
||||
@ -99,12 +100,16 @@ Items_Response :: struct {
|
||||
library_refresh_artists :: () {
|
||||
if app.library.artists_loading return;
|
||||
app.library.artists_loading = true;
|
||||
path := tprint("/Users/%/Items?IncludeItemTypes=MusicArtist&Recursive=true&SortBy=SortName&Limit=2000", app.jellyfin.user_id);
|
||||
// /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");
|
||||
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;
|
||||
|
||||
free(app.library.selected_artist_id);
|
||||
@ -123,8 +128,8 @@ library_select_artist :: (artist_id: string) {
|
||||
app.library.tracks_loading = false;
|
||||
app.library.tracks_scroll = 0;
|
||||
|
||||
path := tprint("/Users/%/Items?IncludeItemTypes=MusicAlbum&ArtistIds=%&Recursive=true&SortBy=ProductionYear,SortName&Limit=2000", app.jellyfin.user_id, artist_id);
|
||||
log_info("library: fetching albums for %", artist_id);
|
||||
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 % (ids=%)", artist_id, all_artist_ids);
|
||||
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);
|
||||
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.id = copy_string(it.Id);
|
||||
a.name = copy_string(it.Name);
|
||||
a.id = copy_string(item_id);
|
||||
a.all_ids = copy_string(item_id);
|
||||
a.name = copy_string(item_name);
|
||||
array_add(*app.library.artists, a);
|
||||
}
|
||||
app.library.artists_loaded = true;
|
||||
@ -245,7 +266,7 @@ on_tracks_loaded :: (task: *Http_Task) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -109,7 +109,7 @@ draw_artists_column :: (x: float, y: float, w: float, h: float) {
|
||||
btn_x := s.x + thumb_size + thumb_pad;
|
||||
btn_w := s.w - thumb_size - thumb_pad;
|
||||
if button(get_rect(btn_x, s.y, btn_w, row_h), it.name, *bt, identifier=it_index) {
|
||||
library_select_artist(it.id);
|
||||
library_select_artist(it.id, it.all_ids);
|
||||
}
|
||||
s.y += row_h * 1.05;
|
||||
}
|
||||
|
||||
@ -385,14 +385,19 @@ draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) {
|
||||
cur_start := lines[active].start_ticks;
|
||||
next_start := lines[active + 1].start_ticks;
|
||||
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,
|
||||
// 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;
|
||||
other_font := app.body_font;
|
||||
line_h := active_font.character_height * 1.75;
|
||||
font := app.body_font;
|
||||
line_h := font.character_height * 1.65;
|
||||
|
||||
cx := x + w * 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;
|
||||
|
||||
d := rel; if d < 0 d = -d;
|
||||
is_active := d < 0.5;
|
||||
|
||||
alpha := 1.1 - d * 0.45;
|
||||
if alpha < 0.0 alpha = 0.0;
|
||||
if alpha > 1.0 alpha = 1.0;
|
||||
if is_active alpha = 1.0;
|
||||
// Lines within 1.5 of active are highlighted; those beyond fade out.
|
||||
alpha: float;
|
||||
if d <= 1.5 {
|
||||
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.
|
||||
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;
|
||||
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;
|
||||
|
||||
lt := app.theme.label_theme;
|
||||
lt.font = ifx is_active then active_font else other_font;
|
||||
lt.font = font;
|
||||
lt.alignment = .Center;
|
||||
lt.text_color = col;
|
||||
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) {
|
||||
line_h := app.body_font.character_height * 1.55;
|
||||
max_lines := cast(int) (h / line_h);
|
||||
if max_lines <= 0 return;
|
||||
lines := app.lyrics.lines;
|
||||
|
||||
n := min(max_lines, app.lyrics.lines.count);
|
||||
block_h := cast(float) n * line_h;
|
||||
sy := y + (h - block_h) * 0.5;
|
||||
// Estimate current line from playback progress.
|
||||
base: float = 0;
|
||||
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.font = app.body_font;
|
||||
lt.font = font;
|
||||
lt.alignment = .Center;
|
||||
lt.text_color = col;
|
||||
label(get_rect(x, sy + cast(float) it * line_h, w, line_h),
|
||||
app.lyrics.lines[it].text, *lt);
|
||||
label(get_rect(x, line_y, w, line_h), lines[it_index].text, *lt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user