add lyrics

This commit is contained in:
Tuomas Katajisto 2026-05-07 20:21:29 +03:00
parent 7c694705db
commit 875a42c244
6 changed files with 235 additions and 2 deletions

Binary file not shown.

View File

@ -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);
}

View File

@ -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;

View File

@ -7,3 +7,4 @@
#load "stream.jai";
#load "favourites.jai";
#load "shuffle.jai";
#load "lyrics.jai";

94
src/jellyfin/lyrics.jai Normal file
View File

@ -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);
}

View File

@ -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;