add lyrics
This commit is contained in:
parent
7c694705db
commit
875a42c244
BIN
build/player
BIN
build/player
Binary file not shown.
@ -138,6 +138,7 @@ on_track_downloaded :: (task: *Http_Task) {
|
|||||||
app.current_stream = stream;
|
app.current_stream = stream;
|
||||||
app.track_finished = false;
|
app.track_finished = false;
|
||||||
media_controls_notify_track();
|
media_controls_notify_track();
|
||||||
|
lyrics_request(app.current_track.id);
|
||||||
log_info("audio: playing '% — %' artist_id=%",
|
log_info("audio: playing '% — %' artist_id=%",
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,10 @@ App :: struct {
|
|||||||
|
|
||||||
// Library browse state.
|
// Library browse state.
|
||||||
library: Library_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;
|
SPECTRUM_BINS :: 64;
|
||||||
|
|||||||
@ -7,3 +7,4 @@
|
|||||||
#load "stream.jai";
|
#load "stream.jai";
|
||||||
#load "favourites.jai";
|
#load "favourites.jai";
|
||||||
#load "shuffle.jai";
|
#load "shuffle.jai";
|
||||||
|
#load "lyrics.jai";
|
||||||
|
|||||||
94
src/jellyfin/lyrics.jai
Normal file
94
src/jellyfin/lyrics.jai
Normal 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);
|
||||||
|
}
|
||||||
@ -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});
|
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);
|
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 slider — under info
|
||||||
seek_y := art_y + art_size * 0.65;
|
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);
|
draw_now_playing_seek(info_x, seek_y, info_w, k * 0.50, has_track);
|
||||||
|
|
||||||
// Transport centered under seek
|
// Transport centered under seek
|
||||||
@ -321,6 +332,128 @@ make_palette_slider_theme :: (font: *Simp.Dynamic_Font) -> Slider_Theme {
|
|||||||
return st;
|
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 {
|
format_seconds :: (s: float) -> string {
|
||||||
if !(s >= 0) return "0:00";
|
if !(s >= 0) return "0:00";
|
||||||
total := cast(int) s;
|
total := cast(int) s;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user