diff --git a/build.jai b/build.jai index 8dafe34..14ec7d1 100644 --- a/build.jai +++ b/build.jai @@ -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); diff --git a/build/player b/build/player index 803e58d..6479128 100755 Binary files a/build/player and b/build/player differ diff --git a/lib/libdr_flac.a b/lib/libdr_flac.a new file mode 100644 index 0000000..93bfbf9 Binary files /dev/null and b/lib/libdr_flac.a differ diff --git a/src/audio/decoders.jai b/src/audio/decoders.jai index 9edc8a6..650d48d 100644 --- a/src/audio/decoders.jai +++ b/src/audio/decoders.jai @@ -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; +} + diff --git a/src/audio/player.jai b/src/audio/player.jai index 51b8636..8bae571 100644 --- a/src/audio/player.jai +++ b/src/audio/player.jai @@ -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); } diff --git a/src/gfx/shaders.jai b/src/gfx/shaders.jai index 599f8ef..421a1d8 100644 --- a/src/gfx/shaders.jai +++ b/src/gfx/shaders.jai @@ -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); } diff --git a/src/jellyfin/async.jai b/src/jellyfin/async.jai index 684aca6..e684d46 100644 --- a/src/jellyfin/async.jai +++ b/src/jellyfin/async.jai @@ -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; diff --git a/src/jellyfin/client.jai b/src/jellyfin/client.jai index dae165e..9d63ffd 100644 --- a/src/jellyfin/client.jai +++ b/src/jellyfin/client.jai @@ -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; diff --git a/src/jellyfin/library.jai b/src/jellyfin/library.jai index 2291403..5e08b16 100644 --- a/src/jellyfin/library.jai +++ b/src/jellyfin/library.jai @@ -10,8 +10,9 @@ // Artist :: struct { - id: string; - name: 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; } Album :: struct { @@ -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); } diff --git a/src/ui/views/library_view.jai b/src/ui/views/library_view.jai index 57eebfa..5ba82ac 100644 --- a/src/ui/views/library_view.jai +++ b/src/ui/views/library_view.jai @@ -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; } diff --git a/src/ui/views/now_playing_view.jai b/src/ui/views/now_playing_view.jai index 2ac0eb7..80e5c44 100644 --- a/src/ui/views/now_playing_view.jai +++ b/src/ui/views/now_playing_view.jai @@ -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); - base += ease(t); + // 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); } }