diff --git a/build/player b/build/player index 54b53ff..803e58d 100755 Binary files a/build/player and b/build/player differ diff --git a/src/audio/player.jai b/src/audio/player.jai index 32d25f0..51b8636 100644 --- a/src/audio/player.jai +++ b/src/audio/player.jai @@ -138,6 +138,7 @@ on_track_downloaded :: (task: *Http_Task) { app.current_stream = stream; 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); } diff --git a/src/core/app.jai b/src/core/app.jai index 636cc0f..df67d72 100644 --- a/src/core/app.jai +++ b/src/core/app.jai @@ -57,6 +57,10 @@ App :: struct { // Library browse state. library: Library_State; + + // Lyrics for the currently-playing track. Populated async after a track + // starts; see jellyfin/lyrics.jai. now_playing_view reads this directly. + lyrics: Lyrics_State; } SPECTRUM_BINS :: 64; diff --git a/src/jellyfin/index.jai b/src/jellyfin/index.jai index 1618c5f..8e88fd7 100644 --- a/src/jellyfin/index.jai +++ b/src/jellyfin/index.jai @@ -7,3 +7,4 @@ #load "stream.jai"; #load "favourites.jai"; #load "shuffle.jai"; +#load "lyrics.jai"; diff --git a/src/jellyfin/lyrics.jai b/src/jellyfin/lyrics.jai new file mode 100644 index 0000000..24dbbf2 --- /dev/null +++ b/src/jellyfin/lyrics.jai @@ -0,0 +1,94 @@ +// +// Jellyfin lyrics. Fetches /Audio/{id}/Lyrics for the current track and +// parses the response into app.lyrics. Synced lines carry a `Start` field in +// 100-ns ticks; unsynced lines all have Start == 0. +// +// Stale-result guard: each fetch stamps app.lyrics_request_gen, and the +// callback discards itself if the generation has moved on (track changed +// before the request returned). +// + +Lyric_Line :: struct { + text: string; + start_ticks: s64; // 100-ns ticks from track start; 0 for unsynced +} + +Lyrics_State :: struct { + track_id: string; // which track these lyrics belong to + lines: [..] Lyric_Line; + synced: bool; // true if any line has a non-zero start + loaded: bool; + loading: bool; + request_gen: int; +} + +lyrics_clear :: (s: *Lyrics_State) { + for s.lines { free(it.text); } + array_reset(*s.lines); + free(s.track_id); + s.track_id = ""; + s.synced = false; + s.loaded = false; + s.loading = false; +} + +lyrics_request :: (track_id: string) { + lyrics_clear(*app.lyrics); + app.lyrics.track_id = copy_string(track_id); + app.lyrics.loading = true; + app.lyrics.request_gen += 1; + + path := tprint("/Audio/%/Lyrics", track_id); + log_info("lyrics: fetching for %", track_id); + http_submit("GET", path, on_done=on_lyrics_loaded, user_data=cast(*void) app.lyrics.request_gen); +} + +#scope_file + +Lyric_Line_Dto :: struct { + Text: string; + Start: s64; // 100-ns ticks; absent → 0 +} + +Lyrics_Response :: struct { + Lyrics: [..] Lyric_Line_Dto; +} + +on_lyrics_loaded :: (task: *Http_Task) { + gen := cast(int) task.user_data; + if gen != app.lyrics.request_gen return; // user moved on; discard + + app.lyrics.loading = false; + + // 404 is the common case: track has no lyrics. Don't log it as an error. + if task.response.status_code == 404 { + app.lyrics.loaded = true; + return; + } + if task.response.status_code == 401 { jellyfin_force_logout(); return; } + if !task.response.ok { + log_warn("lyrics: status=% body=%", task.response.status_code, + slice(task.response.body, 0, min(200, task.response.body.count))); + app.lyrics.loaded = true; + return; + } + + ok, parsed := Jaison.json_parse_string(task.response.body, Lyrics_Response); + if !ok { + log_warn("lyrics: json parse failed"); + app.lyrics.loaded = true; + return; + } + + any_synced := false; + for parsed.Lyrics { + line: Lyric_Line; + line.text = copy_string(it.Text); + line.start_ticks = it.Start; + if it.Start > 0 any_synced = true; + array_add(*app.lyrics.lines, line); + } + app.lyrics.synced = any_synced; + app.lyrics.loaded = true; + log_info("lyrics: % lines (synced=%)", app.lyrics.lines.count, any_synced); +} diff --git a/src/ui/views/now_playing_view.jai b/src/ui/views/now_playing_view.jai index aa2d5e3..2ac0eb7 100644 --- a/src/ui/views/now_playing_view.jai +++ b/src/ui/views/now_playing_view.jai @@ -139,9 +139,14 @@ draw_now_playing_normal :: (w: float, h: float, has_track: bool) { Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1}); } - draw_now_playing_info(info_x, art_y, info_w, has_track, align_center=false); + info_bottom := draw_now_playing_info(info_x, art_y, info_w, has_track, align_center=false); - draw_now_playing_seek(w * 0.14, h * 0.67, w * 0.72, k * 0.55, has_track); + seek_y := h * 0.67; + lyr_y := info_bottom + h * 0.02; + lyr_h := seek_y - lyr_y - h * 0.02; + if lyr_h > 0 draw_now_playing_lyrics(info_x, lyr_y, info_w, lyr_h, has_track); + + draw_now_playing_seek(w * 0.14, seek_y, w * 0.72, k * 0.55, has_track); draw_now_playing_transport(w * 0.5, h * 0.79, k * 2.4, k * 1.5, k * 0.35, has_track); @@ -230,6 +235,12 @@ draw_now_playing_wide :: (w: float, h: float, has_track: bool) { // Seek slider — under info seek_y := art_y + art_size * 0.65; + + // Lyrics panel — wide screens have a dedicated column to the right of info. + side_x := info_x + info_w + w * 0.03; + side_w := w - side_x - w * 0.04; + if side_w > w * 0.12 draw_now_playing_lyrics(side_x, art_y, side_w, art_size, has_track); + draw_now_playing_seek(info_x, seek_y, info_w, k * 0.50, has_track); // Transport centered under seek @@ -321,6 +332,128 @@ make_palette_slider_theme :: (font: *Simp.Dynamic_Font) -> Slider_Theme { return st; } +// ── Lyrics ─────────────────────────────────────────────────────────────────── +// +// Synced lyrics scroll smoothly: the active line lives at the center of the +// rect, lines above/below fade with distance, and the whole stack interpolates +// between (active, active+1) over time so the scroll tracks the music instead +// of snapping. Unsynced lyrics fall back to a static centered block. + +draw_now_playing_lyrics :: (x: float, y: float, w: float, h: float, has_track: bool) { + if !has_track return; + if h < 24 return; + if app.lyrics.lines.count == 0 return; + + if app.lyrics.synced { + cur_ticks: s64 = 0; + if app.current_stream && app.current_stream.sound_data { + rate := cast(float64) app.current_stream.sound_data.sampling_rate; + cur_ticks = cast(s64) (app.current_stream.play_cursor / rate * 10_000_000.0); + } + draw_synced_lyrics(x, y, w, h, cur_ticks); + } else { + draw_static_lyrics(x, y, w, h); + } +} + +ease :: (t: float) -> float { + if t < 0 t = 0; + if t > 1 t = 1; + return t * t * (3.0 - 2.0 * t); +} + +draw_synced_lyrics :: (x: float, y: float, w: float, h: float, cur_ticks: s64) { + lines := app.lyrics.lines; + + // Active = last line whose start has passed. + active := -1; + for lines { + if it.start_ticks <= cur_ticks active = it_index; + else break; + } + + base := cast(float) active; + if active < 0 { + base = -1.0; + first_start := lines[0].start_ticks; + if first_start > 0 { + // Pre-roll: scroll the first line up to center as we approach it. + t := cast(float) cur_ticks / cast(float) first_start; + base = -1.0 + ease(t); + } + } else if active + 1 < lines.count { + 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); + } + } + + active_font := app.row_font; + other_font := app.body_font; + line_h := active_font.character_height * 1.75; + + 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 := 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; + + // Edge fade so lines don't pop in/out at the rect bounds. + top_room := line_y - y; + bot_room := (y + h) - (line_y + line_h); + edge := min(top_room, bot_room) / line_h; + edge = clamp(edge + 0.5, 0, 1); + alpha *= edge; + if alpha < 0.04 continue; + + col := ifx is_active 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.alignment = .Center; + lt.text_color = col; + label(get_rect(x, line_y, w, line_h), lines[it_index].text, *lt); + } +} + +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; + + n := min(max_lines, app.lyrics.lines.count); + block_h := cast(float) n * line_h; + sy := y + (h - block_h) * 0.5; + + col := now_playing_album_color(); + for 0..n-1 { + lt := app.theme.label_theme; + lt.font = app.body_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); + } +} + format_seconds :: (s: float) -> string { if !(s >= 0) return "0:00"; total := cast(int) s;