This commit is contained in:
Tuomas Katajisto 2026-05-01 11:40:59 +03:00
parent c8c69366b5
commit 19dc85821f
24 changed files with 1136 additions and 290 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.DS_Store
.build/
build/player
build/player.dSYM/

View File

@ -26,10 +26,14 @@
## Next likely tasks (in rough order) ## Next likely tasks (in rough order)
1. Free the decoded buffer when the stream ends (fix the leak) 1. ~~Free the decoded buffer when the stream ends (fix the leak)~~ — done via Track_Sound wrapper + release_asset callback.
2. Fetch primary image per album, decode with stb_image, upload as `Simp.Texture`, render in album rows + as backdrop on now-playing 2. **Transcode everything to OGG via Jellyfin** — change the stream URL to `/Audio/{id}/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&userId=...`. Jellyfin re-encodes anything exotic; Sound_Player handles OGG natively via stb_vorbis as `OGG_COMPRESSED` (streams from a decoder, no full PCM buffer). This kills the memory leak for real, lets us delete the entire `audio_decoders` module (dr_mp3/dr_flac), and gives consistent 192 kbps quality regardless of source format. The Track_Sound/release_asset machinery can be simplified back to a plain `Sound_Data`. Only wrinkle: if the Jellyfin server is slow to transcode, first-play latency increases — acceptable trade-off.
3. Real FFT (dr_libs author's work? or kissfft) hooked to Sound_Player's mixed output buffer 2. **Shuffle mode + shuffle-favourites mode** — shuffle toggle in the now-playing transport bar; a second mode that restricts the shuffle pool to favourited tracks only. Jellyfin favourite status lives on the item (`UserData.IsFavorite`); toggling calls `POST /Users/{uid}/FavoriteItems/{id}` / `DELETE ...`.
4. Custom GLSL fragment shader for the visualizer (replaces the bar stack) 3. **Favourite button** — heart/star button on now-playing screen that shows current favourite state and toggles it. Read `IsFavorite` from track JSON, persist optimistically, sync with Jellyfin.
5. Album-level "play all" button in the album column 4. **Cover-art palette theming** — on track change, sample the album art texture (already loaded as a `Simp.Texture`) to extract 34 dominant colours via k-means or a simple median-cut on a downsampled copy of the image pixels. Store as `app.palette [4] Vector4`; the now-playing view and visualizer colour neon bars from the palette instead of the generic hue cycle.
6. Real per-stream pause + seek 5. **New default theme** — less corner rounding, richer/darker base colours (near-black backgrounds, jewel-toned accents), tighter padding. Replace or supplement the current GetRect theme proc.
7. Search bar above artists column 6. ~~**Artist list alpha-jump**~~ — done. TEXT_INPUT events in `draw_library_view` → linear scan → set `artists_scroll`.
6. Custom GLSL fragment shader for the visualizer (replaces the bar stack)
7. Album-level "play all" button in the album column
8. Real per-stream pause + seek
9. Search bar above artists column

Binary file not shown.

BIN
data/.DS_Store vendored Normal file

Binary file not shown.

BIN
data/fonts/bitfont.ttf Normal file

Binary file not shown.

Binary file not shown.

View File

@ -1,21 +1,17 @@
// //
// Real spectrum analysis driving the visualizer. // Real spectrum analysis driving the visualizer.
// //
// Each frame we pull FFT_SIZE PCM frames around the active stream's // Since we now play OGG_COMPRESSED (Sound_Player has no public PCM output),
// play_cursor, mix to mono, window with Hann, FFT, then bin the magnitudes // we maintain a separate stb_vorbis decoder in app.analysis_vorbis. Each
// into SPECTRUM_BINS log-spaced bands. The result lives in `app.spectrum`, // frame we seek it to the current play_cursor position and decode FFT_SIZE
// where `gfx/shaders.jai` reads it for the bar visualizer. // samples for the FFT. Forward seeks (normal playback) are near-instant since
// they just continue from the current bitstream position.
// //
// Behaviour at edges: // play_cursor is in "virtual 44100 Hz sample" units (Sound_Player default);
// - no stream playing → bars decay smoothly to zero // we convert to OGG sample position using the vorbis file's actual rate.
// - paused (play_cursor frozen) → spectrum still decays so the screen
// visibly stops reacting
// - OGG / non-PCM data → we don't have direct sample access, so spectrum
// decays. (All our /universal-fetched tracks decode through dr_libs
// into LINEAR_SAMPLE_ARRAY anyway, so this rarely fires in practice.)
// //
MIN_VISUAL_FREQ :: 30.0; // skip DC and very-low rumble bins MIN_VISUAL_FREQ :: 30.0;
update_audio_analysis :: () { update_audio_analysis :: () {
fft_init(); fft_init();
@ -25,45 +21,57 @@ update_audio_analysis :: () {
return; return;
} }
if !app.analysis_vorbis {
decay_spectrum(0.10);
return;
}
vorbis := cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis;
info := Stb_Vorbis.stb_vorbis_get_info(vorbis);
if info.sample_rate == 0 || info.channels == 0 {
decay_spectrum(0.10);
return;
}
// Convert play_cursor to real elapsed seconds, then to OGG sample position.
// play_cursor advances at play_rate * current_rate per second, where
// current_rate = play_rate / ogg_rate (set by make_stream). So:
// time_s = play_cursor / (play_rate * current_rate)
// ogg_pos = time_s * ogg_rate
sd := app.current_stream.sound_data; sd := app.current_stream.sound_data;
if sd.type != Sound.Sound_Data.Kind.LINEAR_SAMPLE_ARRAY { play_rate := cast(float) sd.sampling_rate; // 44100 default for OGG_COMPRESSED
ogg_rate := cast(float) info.sample_rate; // actual OGG sample rate
current_rate := app.current_stream.current_rate;
if current_rate <= 0 { decay_spectrum(0.10); return; }
time_s := app.current_stream.play_cursor / (play_rate * current_rate);
ogg_frame := cast(s64)(time_s * ogg_rate);
seek_frame := max(0, ogg_frame - FFT_SIZE / 2);
Stb_Vorbis.stb_vorbis_seek_frame(vorbis, cast(u32) seek_frame);
nch := cast(s32) min(info.channels, 2);
decoded := Stb_Vorbis.stb_vorbis_get_samples_short_interleaved(
vorbis, nch, analysis_pcm_buf.data, FFT_SIZE * nch);
if decoded < FFT_SIZE / 4 {
decay_spectrum(0.10); decay_spectrum(0.10);
return; return;
} }
nchannels := cast(s64) sd.nchannels; // Mix to mono and apply Hann window.
if nchannels < 1 { inv_chan := 1.0 / cast(float) nch;
decay_spectrum(0.10);
return;
}
total_frames := sd.nsamples_times_nchannels / nchannels;
cursor_frame := cast(s64) app.current_stream.play_cursor;
start := cursor_frame - FFT_SIZE / 2;
if start < 0 start = 0;
if start + FFT_SIZE > total_frames start = total_frames - FFT_SIZE;
if start < 0 {
// track shorter than the FFT window
decay_spectrum(0.10);
return;
}
// Mix down to mono and apply window.
samples := sd.samples;
inv_chan := 1.0 / cast(float) nchannels;
for k: 0..FFT_SIZE-1 { for k: 0..FFT_SIZE-1 {
idx := (start + k) * nchannels; if k >= decoded { fft_re[k] = 0; fft_im[k] = 0; continue; }
sum: float = 0; sum: float = 0;
for ch: 0..nchannels-1 sum += cast(float) samples[idx + ch]; for ch: 0..nch-1 sum += cast(float) analysis_pcm_buf[k * nch + ch];
mono := sum * inv_chan / 32768.0; // s16 → [-1, 1] mono := sum * inv_chan / 32768.0;
fft_re[k] = mono * fft_window[k]; fft_re[k] = mono * fft_window[k];
fft_im[k] = 0; fft_im[k] = 0;
} }
fft(); fft();
// Bin into log-spaced bands. rate := ogg_rate;
rate := cast(float) sd.sampling_rate;
nyquist := rate * 0.5; nyquist := rate * 0.5;
log_lo := log(MIN_VISUAL_FREQ); log_lo := log(MIN_VISUAL_FREQ);
log_hi := log(nyquist); log_hi := log(nyquist);
@ -85,15 +93,10 @@ update_audio_analysis :: () {
if mag > peak peak = mag; if mag > peak peak = mag;
} }
// Normalize by FFT_SIZE/4 (a Hann-windowed unit sine peaks around
// FFT_SIZE/4 in this convention) and compress with sqrt to match
// perceptual loudness better.
v := peak / (cast(float) FFT_SIZE * 0.25); v := peak / (cast(float) FFT_SIZE * 0.25);
v = sqrt(v); v = sqrt(v);
if v > 1 v = 1; if v > 1 v = 1;
// Asymmetric smoothing: fast attack, slow decay (classic
// VU-meter feel).
prev := app.spectrum[b]; prev := app.spectrum[b];
rate_lerp := ifx v > prev then 0.45 else 0.10; rate_lerp := ifx v > prev then 0.45 else 0.10;
app.spectrum[b] = lerp(prev, v, rate_lerp); app.spectrum[b] = lerp(prev, v, rate_lerp);
@ -102,6 +105,8 @@ update_audio_analysis :: () {
#scope_file #scope_file
analysis_pcm_buf: [FFT_SIZE * 2] s16;
decay_spectrum :: (rate: float) { decay_spectrum :: (rate: float) {
for 0..SPECTRUM_BINS-1 { for 0..SPECTRUM_BINS-1 {
app.spectrum[it] = lerp(app.spectrum[it], 0, rate); app.spectrum[it] = lerp(app.spectrum[it], 0, rate);

View File

@ -1,11 +1,12 @@
// //
// Audio playback. The track download is async — `audio_play_track` returns // Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes
// immediately after queueing the request; when the bytes arrive we decode // everything server-side) and hands it to Sound_Player's native OGG path.
// them and start a Sound_Player stream.
// //
// Pause is REAL: setting `current_rate = desired_rate = 0` freezes the // OGG_COMPRESSED streams from a decoder rather than decoding the full PCM
// stream's play_cursor and (with `inaudible = true`) silences output. So // into memory, so there is no large heap buffer to manage — Sound_Player
// while paused the seek bar doesn't drift and auto-advance can't fire. // owns the decoder lifecycle. The only memory we own is the heap-allocated
// Sound_Data struct and the raw OGG bytes (stored in sound_data.buffer);
// both are freed in sound_release_callback when Sound_Player is done.
// //
audio_play_track :: (track: Track) { audio_play_track :: (track: Track) {
@ -14,12 +15,17 @@ audio_play_track :: (track: Track) {
pending := New(Track); pending := New(Track);
pending.* = clone_track(track); pending.* = clone_track(track);
// /universal lets the server pick the best format, transcoding to mp3 // Ask Jellyfin to transcode everything to OGG Vorbis 192 kbps.
// when the original isn't in our `container` list. Without this, Opus / // We pin audioSampleRate to Sound_Player's actual output device rate so
// M4A / AAC / WMA / ALAC files come back as bytes we can't decode. // 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.
output_hz := Sound.get_audio_sampling_rate();
if output_hz <= 0 output_hz = 44100;
path := tprint( path := tprint(
"/Audio/%/universal?container=mp3,flac,ogg,wav&audioCodec=mp3&maxStreamingBitrate=320000&userId=%&deviceId=%&api_key=%", "/Audio/%/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&audioSampleRate=%&audioChannels=2&userId=%&deviceId=%&api_key=%",
track.id, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token, track.id, output_hz, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token,
); );
log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks); 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=pending);
@ -58,7 +64,23 @@ stop_current_stream :: () {
app.current_stream = null; app.current_stream = null;
} }
analysis_close :: () {
if app.analysis_vorbis {
Stb_Vorbis.stb_vorbis_close(cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis);
app.analysis_vorbis = null;
}
free(app.analysis_ogg);
app.analysis_ogg = "";
}
// Called by Sound_Player when it is finished with a stream (natural end or
// stop_stream_abruptly). We own the Sound_Data struct and the OGG bytes in
// sound_data.buffer; free both here.
sound_release_callback :: (stream: *Sound.Sound_Stream, data: *Sound.Sound_Data) { sound_release_callback :: (stream: *Sound.Sound_Stream, data: *Sound.Sound_Data) {
free(data.buffer);
free(data.name);
free(data);
if stream && stream.entity_id == app.track_entity_id { if stream && stream.entity_id == app.track_entity_id {
app.track_finished = true; app.track_finished = true;
app.current_stream = null; app.current_stream = null;
@ -72,8 +94,10 @@ clone_track :: (t: Track) -> Track {
out.album = copy_string(t.album); out.album = copy_string(t.album);
out.album_id = copy_string(t.album_id); out.album_id = copy_string(t.album_id);
out.artist = copy_string(t.artist); out.artist = copy_string(t.artist);
out.artist_id = copy_string(t.artist_id);
out.duration_ticks = t.duration_ticks; out.duration_ticks = t.duration_ticks;
out.index_number = t.index_number; out.index_number = t.index_number;
out.is_favourite = t.is_favourite;
return out; return out;
} }
@ -83,6 +107,7 @@ free_track :: (t: *Track) {
free(t.album); free(t.album);
free(t.album_id); free(t.album_id);
free(t.artist); free(t.artist);
free(t.artist_id);
t.* = .{}; t.* = .{};
} }
@ -97,25 +122,36 @@ on_track_downloaded :: (task: *Http_Task) {
return; return;
} }
// Transfer ownership of the bytes to Sound_Data.buffer.
bytes := task.response.body; bytes := task.response.body;
task.response.body = ""; task.response.body = "";
sd, format, ok := decode_audio(bytes, pending.name); sd := Sound.load_audio_data(pending.name, bytes);
if !ok { if !sd.loaded {
log_error("audio decode failed for '%'", pending.name); log_error("audio: OGG decode failed for '%'", pending.name);
free(bytes); free(bytes);
return; return;
} }
data := New(Sound.Sound_Data); data := New(Sound.Sound_Data);
data.* = sd; data.* = sd;
// Open a separate OGG decoder for the visualizer FFT before we transfer
// the bytes to Sound_Player (bytes is still valid at this point).
analysis_close();
app.analysis_ogg = copy_string(bytes); // independent copy — Sound_Player owns `bytes`
{
err: s32;
v := Stb_Vorbis.stb_vorbis_open_memory(
app.analysis_ogg.data, cast(s32) app.analysis_ogg.count, *err, null);
if v app.analysis_vorbis = v;
else log_warn("audio: analysis decoder open failed (err=%)", err);
}
stop_current_stream(); stop_current_stream();
free_track(*app.current_track); free_track(*app.current_track);
app.current_track = clone_track(pending.*); app.current_track = clone_track(pending.*);
app.current_format = format; app.current_format = .OGG;
// New track always starts un-paused; user clicking Next/Prev or
// auto-advance both want fresh playback state.
app.paused = false; app.paused = false;
app.track_entity_id += 1; app.track_entity_id += 1;
@ -125,5 +161,6 @@ on_track_downloaded :: (task: *Http_Task) {
app.current_stream = stream; app.current_stream = stream;
app.track_finished = false; app.track_finished = false;
log_info("audio: playing '% — %' [%]", app.current_track.artist, app.current_track.name, format); log_info("audio: playing '% — %' [OGG] artist_id=%",
app.current_track.artist, app.current_track.name, app.current_track.artist_id);
} }

View File

@ -1,36 +1,117 @@
// //
// Naive play queue. Linear array, current index. Shuffle/repeat punted. // Play queue with optional shuffle.
// audio_play_track is now async (returns void); the queue just fires the
// download and trusts it to land.
// //
// Shuffle mode cycles: OFF → ALL → FAVOURITES → OFF.
// In shuffle modes, queue_next/prev walk a Fisher-Yates permutation of track
// indices rather than sequential order. FAVOURITES filters the pool to tracks
// where is_favourite is true.
//
Shuffle_Mode :: enum {
OFF;
ALL;
FAVOURITES;
}
Queue :: struct { Queue :: struct {
tracks: [..] Track; tracks: [..] Track;
current: int = -1; current: int = -1;
shuffle_mode: Shuffle_Mode;
shuffle_order: [..] int; // permuted indices into tracks[]
shuffle_pos: int = -1; // current position within shuffle_order
} }
queue: Queue; queue: Queue;
queue_clear :: () { queue_clear :: () {
array_reset(*queue.tracks); array_reset(*queue.tracks);
array_reset(*queue.shuffle_order);
queue.current = -1; queue.current = -1;
queue.shuffle_pos = -1;
} }
queue_add :: (track: Track) { queue_add :: (track: Track) {
array_add(*queue.tracks, track); array_add(*queue.tracks, track);
} }
// Build a fresh permutation from the current track list. Call after all
// queue_adds are done, and whenever shuffle mode changes.
queue_rebuild_shuffle :: () {
array_reset(*queue.shuffle_order);
if queue.shuffle_mode == .OFF return;
for i: 0..queue.tracks.count-1 {
if queue.shuffle_mode == .FAVOURITES && !queue.tracks[i].is_favourite continue;
array_add(*queue.shuffle_order, i);
}
// Fisher-Yates — must use while loop; Jai's a..b range goes forward when
// a < b, so `for i: n-1..1` would iterate upward and crash on n=1.
i := queue.shuffle_order.count - 1;
while i > 0 {
j := cast(int)(random_get() % cast(u64)(i + 1));
tmp := queue.shuffle_order[i];
queue.shuffle_order[i] = queue.shuffle_order[j];
queue.shuffle_order[j] = tmp;
i -= 1;
}
// Put the current track at position 0 so << works sensibly immediately.
if queue.current >= 0 {
for i: 0..queue.shuffle_order.count-1 {
if queue.shuffle_order[i] == queue.current {
tmp := queue.shuffle_order[0];
queue.shuffle_order[0] = queue.shuffle_order[i];
queue.shuffle_order[i] = tmp;
break;
}
}
}
queue.shuffle_pos = 0;
}
queue_set_shuffle :: (mode: Shuffle_Mode) {
if queue.shuffle_mode == mode return;
queue.shuffle_mode = mode;
queue_rebuild_shuffle();
}
queue_play_index :: (index: int) -> bool { queue_play_index :: (index: int) -> bool {
if index < 0 || index >= queue.tracks.count return false; if index < 0 || index >= queue.tracks.count return false;
queue.current = index; queue.current = index;
if queue.shuffle_mode != .OFF {
for i: 0..queue.shuffle_order.count-1 {
if queue.shuffle_order[i] == index {
queue.shuffle_pos = i;
break;
}
}
}
audio_play_track(queue.tracks[index]); audio_play_track(queue.tracks[index]);
return true; return true;
} }
queue_next :: () -> bool { queue_next :: () -> bool {
if queue.shuffle_mode != .OFF && queue.shuffle_order.count > 0 {
next := queue.shuffle_pos + 1;
if next >= queue.shuffle_order.count return false;
queue.shuffle_pos = next;
queue.current = queue.shuffle_order[next];
audio_play_track(queue.tracks[queue.current]);
return true;
}
return queue_play_index(queue.current + 1); return queue_play_index(queue.current + 1);
} }
queue_prev :: () -> bool { queue_prev :: () -> bool {
if queue.shuffle_mode != .OFF && queue.shuffle_order.count > 0 {
prev := queue.shuffle_pos - 1;
if prev < 0 return false;
queue.shuffle_pos = prev;
queue.current = queue.shuffle_order[prev];
audio_play_track(queue.tracks[queue.current]);
return true;
}
return queue_play_index(queue.current - 1); return queue_play_index(queue.current - 1);
} }

View File

@ -49,6 +49,16 @@ App :: struct {
// Per-frame audio analysis used by the visualizer shader. // Per-frame audio analysis used by the visualizer shader.
spectrum: [SPECTRUM_BINS] float; spectrum: [SPECTRUM_BINS] float;
// Separate OGG decoder kept open for the visualizer FFT. Sound_Player's
// OGG_COMPRESSED type gives no PCM access, so we maintain our own handle.
analysis_vorbis: *void; // *Stb_Vorbis.stb_vorbis, void to avoid header dep
analysis_ogg: string; // copy of OGG bytes kept alive for the decoder
// Cover art palette — extracted once per album, used for theming.
palette: [4] Vector4; // [0]=bg [1]=highlight [2]=mid [3]=accent
palette_ready: bool;
palette_album_id: string;
// Library browse state. // Library browse state.
library: Library_State; library: Library_State;
} }
@ -62,7 +72,7 @@ app_init :: () {
setup_data_directory(); setup_data_directory();
app.window = create_window(app.window_width, app.window_height, "player"); app.window = create_window(app.window_width, app.window_height, "Celica");
Simp.set_render_target(app.window, .LEFT_HANDED); Simp.set_render_target(app.window, .LEFT_HANDED);
init_fonts(); init_fonts();

View File

@ -22,3 +22,5 @@ Sound :: #import "Sound_Player";
Jaison :: #import "Jaison"; Jaison :: #import "Jaison";
Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true); Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true);
Audio_Decoders :: #import "audio_decoders"; Audio_Decoders :: #import "audio_decoders";
Stb_Image :: #import "stb_image";
Stb_Vorbis :: #import "stb_vorbis";

View File

@ -45,8 +45,7 @@ run_main_loop :: () {
} }
draw_one_frame :: () { draw_one_frame :: () {
proc := default_theme_procs[app.current_theme]; app.theme = player_theme();
app.theme = proc();
set_default_theme(app.theme); set_default_theme(app.theme);
bg := app.theme.background_color; bg := app.theme.background_color;
@ -55,8 +54,9 @@ draw_one_frame :: () {
x, y, w, h := get_dimensions(app.window, true); x, y, w, h := get_dimensions(app.window, true);
ui_per_frame_update(app.window, w, h, app.current_time); ui_per_frame_update(app.window, w, h, app.current_time);
// The visualizer shader runs *behind* the UI for every view that wants it. // Library: visualizer behind the UI. Now-playing: visualizer is drawn
if app.current_view != .LOGIN { // inside draw_now_playing_view, after the backdrop, so bars sit on top.
if app.current_view == .LIBRARY {
gfx_draw_visualizer_background(xx w, xx h); gfx_draw_visualizer_background(xx w, xx h);
} }

View File

@ -82,7 +82,13 @@ image_request :: (item_id: string, size: Image_Size) -> *Image {
req.image = img; req.image = img;
req.id = copy_string(item_id); req.id = copy_string(item_id);
req.size = size; req.size = size;
// Backdrop and large images are needed immediately for the now-playing
// screen; insert at front so they don't queue behind hundreds of thumbs.
if size == .BACKDROP || size == .LARGE {
array_insert_at(*image_pending, req, 0);
} else {
array_add(*image_pending, req); array_add(*image_pending, req);
}
return img; return img;
} }
@ -106,6 +112,7 @@ image_pump :: () {
// Do NOT increment i — the array shifted. // Do NOT increment i — the array shifted.
} else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES { } else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES {
url := build_image_url(req.id, req.size); url := build_image_url(req.id, req.size);
log_info("image: fetching % (size=%) url=%", req.id, req.size, url);
http_submit("GET", url, on_done=on_image_response, user_data=req); http_submit("GET", url, on_done=on_image_response, user_data=req);
image_in_flight += 1; image_in_flight += 1;
array_ordered_remove_by_index(*image_pending, i); array_ordered_remove_by_index(*image_pending, i);
@ -117,6 +124,39 @@ image_pump :: () {
} }
} }
// Render image filling the rect with cover-fit: maintains aspect ratio,
// crops the excess. The center of the image is always centered in the rect.
draw_image_cover :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
if !img || !img.loaded {
Simp.set_shader_for_color();
col := ifx img && img.failed then Vector4.{0.18, 0.04, 0.10, 1} else Vector4.{0.10, 0.05, 0.18, 1};
Simp.immediate_quad(x, y, x + w, y + h, col);
return;
}
iw := cast(float) img.texture.width;
ih := cast(float) img.texture.height;
if iw < 1 || ih < 1 { draw_image(x, y, w, h, img, tint); return; }
scale := max(w / iw, h / ih);
u_range := w / (iw * scale);
v_range := h / (ih * scale);
u0 := (1.0 - u_range) * 0.5;
v0 := (1.0 - v_range) * 0.5;
u1 := u0 + u_range;
v1 := v0 + v_range;
Simp.set_shader_for_images(*img.texture);
Simp.immediate_quad(
Vector2.{x, y },
Vector2.{x + w, y },
Vector2.{x + w, y + h},
Vector2.{x, y + h},
color = tint,
uv0 = .{u0, v0}, uv1 = .{u1, v0},
uv2 = .{u1, v1}, uv3 = .{u0, v1},
);
}
draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) { draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
if img && img.loaded { if img && img.loaded {
Simp.set_shader_for_images(*img.texture); Simp.set_shader_for_images(*img.texture);
@ -192,20 +232,30 @@ consume_disk_hit :: (req: *Image_Request, disk_path: string) {
img := req.image; img := req.image;
img.loading = false; img.loading = false;
log_info("image: disk hit id=% size=%", req.id, req.size);
bytes, ok := read_entire_file(disk_path, log_errors=false); bytes, ok := read_entire_file(disk_path, log_errors=false);
if !ok { if !ok {
log_warn("image: disk read failed id=% size=%", req.id, req.size);
img.failed = true; img.failed = true;
return; return;
} }
defer free(bytes); defer free(bytes);
if req.size == .LARGE palette_extract(req.id, bytes);
buf: [] u8 = ---; buf: [] u8 = ---;
buf.data = bytes.data; buf.data = bytes.data;
buf.count = bytes.count; buf.count = bytes.count;
if !Simp.texture_load_from_memory(*img.texture, buf) { if !Simp.texture_load_from_memory(*img.texture, buf) {
// Cached file is corrupt (e.g. a Jellyfin error response saved as
// bytes). Delete it so the next launch re-fetches.
log_warn("image: corrupt disk cache id=% size=%, deleting %", req.id, req.size, disk_path);
file_delete(disk_path);
img.failed = true; img.failed = true;
return; return;
} }
log_info("image: disk loaded id=% size=% %x%", req.id, req.size, img.texture.width, img.texture.height);
img.loaded = true; img.loaded = true;
} }
@ -219,6 +269,8 @@ on_image_response :: (task: *Http_Task) {
img.loading = false; img.loading = false;
if !task.response.ok || task.response.body.count == 0 { if !task.response.ok || task.response.body.count == 0 {
log_warn("image: FAILED id=% size=% status=% body_len=%",
req.id, req.size, task.response.status_code, task.response.body.count);
img.failed = true; img.failed = true;
return; return;
} }
@ -229,12 +281,17 @@ on_image_response :: (task: *Http_Task) {
disk_path := image_disk_path(req.id, req.size); disk_path := image_disk_path(req.id, req.size);
write_entire_file(disk_path, task.response.body); write_entire_file(disk_path, task.response.body);
if req.size == .LARGE palette_extract(req.id, task.response.body);
body_bytes: [] u8 = ---; body_bytes: [] u8 = ---;
body_bytes.data = task.response.body.data; body_bytes.data = task.response.body.data;
body_bytes.count = task.response.body.count; body_bytes.count = task.response.body.count;
if !Simp.texture_load_from_memory(*img.texture, body_bytes) { if !Simp.texture_load_from_memory(*img.texture, body_bytes) {
log_warn("image: texture decode failed id=% size=% body_len=%", req.id, req.size, task.response.body.count);
img.failed = true; img.failed = true;
return; return;
} }
log_info("image: loaded id=% size=% %x% body_len=%",
req.id, req.size, img.texture.width, img.texture.height, task.response.body.count);
img.loaded = true; img.loaded = true;
} }

View File

@ -1,2 +1,3 @@
#load "shaders.jai"; #load "shaders.jai";
#load "images.jai"; #load "images.jai";
#load "palette.jai";

165
src/gfx/palette.jai Normal file
View File

@ -0,0 +1,165 @@
//
// Cover-art palette extraction.
//
// Called from image loading paths whenever a LARGE (album art) image finishes
// loading. Decodes the raw bytes with stb_image, samples ~400 pixels evenly
// across the image, buckets them by hue, and picks the most saturated-and-
// bright representative from each bucket. Grays and near-blacks are discarded
// so the result is always a set of distinct, vivid colours.
//
// app.palette layout:
// [0] bg — dominant hue, darkened (for background tint)
// [1] highlight — dominant hue, vivid (track title, visualizer)
// [2] mid — second colour, vivid (artist name, mid bars)
// [3] accent — third colour, vivid (album, bar tips)
//
palette_extract :: (album_id: string, bytes: string) {
if album_id == app.palette_album_id return;
if !bytes.data || bytes.count == 0 return;
w, h, ch: s32;
pixels := Stb_Image.stbi_load_from_memory(
bytes.data, cast(s32) bytes.count, *w, *h, *ch, 3);
if !pixels return;
defer Stb_Image.stbi_image_free(pixels);
HUE_BUCKETS :: 12; // 30-degree slices — enough to separate reds, oranges, etc.
bucket_r: [HUE_BUCKETS] float;
bucket_g: [HUE_BUCKETS] float;
bucket_b: [HUE_BUCKETS] float;
bucket_score: [HUE_BUCKETS] float;
total := w * h;
step := max(1, total / 400);
i := 0;
while i < total {
px := pixels + i * 3;
r := cast(float) px[0] / 255.0;
g := cast(float) px[1] / 255.0;
b := cast(float) px[2] / 255.0;
hue, sat, val := rgb_to_hsv(r, g, b);
// Discard near-grays (low saturation) and near-blacks.
if sat < 0.25 || val < 0.15 { i += step; continue; }
// Score peaks for medium-bright, highly saturated colours.
score := sat * (0.35 + 0.65 * val);
bucket := cast(int)(hue * HUE_BUCKETS / 360.0);
if bucket >= HUE_BUCKETS bucket = HUE_BUCKETS - 1;
if score > bucket_score[bucket] {
bucket_score[bucket] = score;
bucket_r[bucket] = r;
bucket_g[bucket] = g;
bucket_b[bucket] = b;
}
i += step;
}
// Collect non-empty buckets and sort by score descending.
order: [HUE_BUCKETS] int;
count := 0;
for 0..HUE_BUCKETS-1 {
if bucket_score[it] > 0 {
order[count] = it;
count += 1;
}
}
if count > 1 {
for outer: 1..count-1 {
j := outer;
while j > 0 && bucket_score[order[j]] > bucket_score[order[j-1]] {
tmp := order[j]; order[j] = order[j-1]; order[j-1] = tmp;
j -= 1;
}
}
}
// Defaults (keep existing purple aesthetic when image has no strong colour).
app.palette[0] = .{0.04, 0.02, 0.08, 1};
app.palette[1] = .{1.0, 0.4, 0.8, 1};
app.palette[2] = .{0.4, 0.8, 1.0, 1};
app.palette[3] = .{0.5, 1.0, 0.6, 1};
if count >= 1 {
r0 := bucket_r[order[0]];
g0 := bucket_g[order[0]];
b0 := bucket_b[order[0]];
h0, s0, v0 := rgb_to_hsv(r0, g0, b0);
// bg: dominant hue, very dark so text stays readable
app.palette[0] = hsv_to_rgb4(h0, min(s0 * 0.8, 0.85), v0 * 0.22);
// highlight: dominant hue pushed vivid and bright
app.palette[1] = hsv_to_rgb4(h0, max(s0, 0.80), max(v0, 0.85));
}
if count >= 2 {
r1 := bucket_r[order[1]];
g1 := bucket_g[order[1]];
b1 := bucket_b[order[1]];
h1, s1, v1 := rgb_to_hsv(r1, g1, b1);
app.palette[2] = hsv_to_rgb4(h1, max(s1, 0.70), max(v1, 0.75));
}
if count >= 3 {
r2 := bucket_r[order[2]];
g2 := bucket_g[order[2]];
b2 := bucket_b[order[2]];
h2, s2, v2 := rgb_to_hsv(r2, g2, b2);
app.palette[3] = hsv_to_rgb4(h2, max(s2, 0.60), max(v2, 0.70));
}
free(app.palette_album_id);
app.palette_album_id = copy_string(album_id);
app.palette_ready = true;
log_info("palette: album=% hi=(%.2,%.2,%.2) bg=(%.2,%.2,%.2) found % hue buckets",
album_id,
app.palette[1].x, app.palette[1].y, app.palette[1].z,
app.palette[0].x, app.palette[0].y, app.palette[0].z,
count);
}
#scope_file
rgb_to_hsv :: (r: float, g: float, b: float) -> (hue: float, sat: float, val: float) {
cmax := max(r, max(g, b));
cmin := min(r, min(g, b));
delta := cmax - cmin;
val := cmax;
sat := ifx cmax > 0.001 then delta / cmax else 0.0;
hue: float;
if delta > 0.001 {
if cmax == r {
hue = 60.0 * ((g - b) / delta);
if hue < 0 hue += 360.0;
} else if cmax == g {
hue = 60.0 * ((b - r) / delta + 2.0);
} else {
hue = 60.0 * ((r - g) / delta + 4.0);
}
}
return hue, sat, val;
}
hsv_to_rgb4 :: (h: float, s: float, v: float) -> Vector4 {
if s < 0.001 return .{v, v, v, 1};
hh := h / 60.0;
i := cast(int) hh;
f := hh - cast(float) i;
p := v * (1.0 - s);
q := v * (1.0 - s * f);
t := v * (1.0 - s * (1.0 - f));
r, g, b: float;
if i == 0 { r=v; g=t; b=p; }
else if i == 1 { r=q; g=v; b=p; }
else if i == 2 { r=p; g=v; b=t; }
else if i == 3 { r=p; g=q; b=v; }
else if i == 4 { r=t; g=p; b=v; }
else { r=v; g=p; b=q; }
return .{r, g, b, 1};
}

View File

@ -12,27 +12,28 @@ gfx_init :: () {
// Nothing yet. // Nothing yet.
} }
gfx_draw_visualizer_background :: (w: float, h: float) { gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
update_audio_analysis(); update_audio_analysis();
Simp.set_shader_for_color();
t := cast(float) app.current_time; t := cast(float) app.current_time;
// Bass = average of the lowest ~16% of bins. Drives a subtle base
// brightness pulse so the whole screen breathes with the kick drum.
bass: float = 0; bass: float = 0;
bass_bins := SPECTRUM_BINS / 6; bass_bins := SPECTRUM_BINS / 6;
if bass_bins < 1 bass_bins = 1; if bass_bins < 1 bass_bins = 1;
for 0..bass_bins-1 bass += app.spectrum[it]; for 0..bass_bins-1 bass += app.spectrum[it];
bass /= cast(float) bass_bins; bass /= cast(float) bass_bins;
base := Vector4.{ {
0.04 + 0.10 * bass, base: Vector4;
0.02 + 0.04 * bass, if app.palette_ready {
0.10 + 0.18 * bass, p := app.palette[0];
1, base = .{p.x + 0.08 * bass, p.y + 0.04 * bass, p.z + 0.12 * bass, bg_alpha};
}; } else {
base = .{0.04 + 0.10 * bass, 0.02 + 0.04 * bass, 0.10 + 0.18 * bass, bg_alpha};
}
Simp.set_shader_for_color(enable_blend = bg_alpha < 1.0);
Simp.immediate_quad(0, 0, w, h, base); Simp.immediate_quad(0, 0, w, h, base);
}
// Mirrored bars from the horizontal mid-line — bass on the left, treble // Mirrored bars from the horizontal mid-line — bass on the left, treble
// on the right, both spreading top and bottom. // on the right, both spreading top and bottom.
@ -47,11 +48,37 @@ gfx_draw_visualizer_background :: (w: float, h: float) {
x1 := x0 + bar_w * 0.85; x1 := x0 + bar_w * 0.85;
hue := cast(float) i / cast(float) SPECTRUM_BINS; hue := cast(float) i / cast(float) SPECTRUM_BINS;
body := neon_color(hue, v, t); body: Vector4;
if app.palette_ready {
// 4-stop gradient across the spectrum using all palette colours.
// hue goes 0..1 left-to-right; map onto 3 segments:
// [0, 0.33] palette[1] → palette[2]
// [0.33,0.66] palette[2] → palette[3]
// [0.66, 1] palette[3] → palette[1] (wraps back for continuity)
c: Vector4;
if hue < 0.333 {
t2 := hue / 0.333;
c1 := app.palette[1]; c2 := app.palette[2];
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
} else if hue < 0.666 {
t2 := (hue - 0.333) / 0.333;
c1 := app.palette[2]; c2 := app.palette[3];
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
} else {
t2 := (hue - 0.666) / 0.334;
c1 := app.palette[3]; c2 := app.palette[1];
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
}
// Modulate brightness by amplitude; dim quiet bars a little.
bright := 0.45 + 0.55 * v;
body = .{c.x * bright, c.y * bright, c.z * bright, 0.65 + 0.35 * v};
} else {
body = neon_color(hue, v, t);
}
tip := body; tip := body;
tip.x = min(1.0, tip.x + 0.4); tip.x = min(1.0, tip.x + 0.35);
tip.y = min(1.0, tip.y + 0.4); tip.y = min(1.0, tip.y + 0.35);
tip.z = min(1.0, tip.z + 0.4); tip.z = min(1.0, tip.z + 0.35);
// Top half (going up). // Top half (going up).
Simp.immediate_quad(x0, cy - bar_h, x1, cy, body); Simp.immediate_quad(x0, cy - bar_h, x1, cy, body);
@ -66,11 +93,11 @@ gfx_draw_visualizer_background :: (w: float, h: float) {
} }
} }
// Center mirror line — a thin neon glow across the middle for the // Center mirror line — a thin neon glow across the middle.
// 2000s-skin vibe.
{ {
line_h := 1.5; line_h := 1.5;
line_col := Vector4.{1.0, 0.4, 0.8, 0.5 + 0.3 * bass}; 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); Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col);
} }
} }

View File

@ -0,0 +1,22 @@
//
// Jellyfin favourite item API. Optimistic local update + fire-and-forget HTTP.
//
jellyfin_toggle_favourite :: (track_id: string, currently_favourite: bool) {
path := tprint("/Users/%/FavoriteItems/%", app.jellyfin.user_id, track_id);
method := ifx currently_favourite then "DELETE" else "POST";
log_info("fav: % %", method, path);
http_submit(method, path, on_done=on_fav_response);
}
#scope_file
on_fav_response :: (task: *Http_Task) {
if task.response.ok {
log_info("fav: server accepted (status=%)", task.response.status_code);
} else {
log_warn("fav: server rejected (status=%) body=%",
task.response.status_code,
slice(task.response.body, 0, min(200, task.response.body.count)));
}
}

View File

@ -4,3 +4,5 @@
#load "library.jai"; #load "library.jai";
#load "images.jai"; #load "images.jai";
#load "stream.jai"; #load "stream.jai";
#load "favourites.jai";
#load "shuffle.jai";

View File

@ -30,6 +30,7 @@ Track :: struct {
artist_id: string; artist_id: string;
duration_ticks: s64; duration_ticks: s64;
index_number: int; index_number: int;
is_favourite: bool;
} }
Library_State :: struct { Library_State :: struct {
@ -60,13 +61,26 @@ Library_State :: struct {
#scope_module #scope_module
// Jellyfin returns the artist ID inside AlbumArtists[].Id, not as a flat
// AlbumArtistId field on track items.
Artist_Ref :: struct {
Id: string;
Name: string;
}
User_Data_Dto :: struct {
IsFavorite: bool;
}
Item_Summary :: struct { Item_Summary :: struct {
Id: string; Id: string;
Name: string; Name: string;
Album: string; Album: string;
AlbumId: string; AlbumId: string;
AlbumArtist: string; AlbumArtist: string;
AlbumArtistId: string; AlbumArtistId: string; // populated for album items
AlbumArtists: [..] Artist_Ref; // populated for track items
UserData: User_Data_Dto;
RunTimeTicks: s64; RunTimeTicks: s64;
IndexNumber: int; IndexNumber: int;
} }
@ -215,9 +229,12 @@ on_tracks_loaded :: (task: *Http_Task) {
t.album = copy_string(it.Album); t.album = copy_string(it.Album);
t.album_id = copy_string(it.AlbumId); t.album_id = copy_string(it.AlbumId);
t.artist = copy_string(it.AlbumArtist); t.artist = copy_string(it.AlbumArtist);
t.artist_id = copy_string(it.AlbumArtistId); t.artist_id = ifx it.AlbumArtists.count > 0
then copy_string(it.AlbumArtists[0].Id)
else copy_string(it.AlbumArtistId); // fallback
t.duration_ticks = it.RunTimeTicks; t.duration_ticks = it.RunTimeTicks;
t.index_number = it.IndexNumber; t.index_number = it.IndexNumber;
t.is_favourite = it.UserData.IsFavorite;
array_add(*app.library.tracks, t); array_add(*app.library.tracks, t);
} }
app.library.tracks_loaded = true; app.library.tracks_loaded = true;

83
src/jellyfin/shuffle.jai Normal file
View File

@ -0,0 +1,83 @@
//
// Global shuffle — fetches all tracks (or all favourites) from the Jellyfin
// library and starts playing immediately. Uses the same Items_Response / Track
// parsing path as library browsing.
//
library_start_global_shuffle :: (mode: Shuffle_Mode) {
req := New(Shuffle_Fetch);
req.mode = mode;
path: string;
if mode == .FAVOURITES {
path = tprint(
"/Users/%/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Filters=IsFavorite&Limit=2000",
app.jellyfin.user_id);
} else {
path = tprint(
"/Users/%/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=2000",
app.jellyfin.user_id);
}
log_info("shuffle: fetching % tracks from library", ifx mode == .FAVOURITES then "favourite" else "all");
http_submit("GET", path, on_done=on_shuffle_tracks_loaded, user_data=req);
}
#scope_file
Shuffle_Fetch :: struct {
mode: Shuffle_Mode;
}
on_shuffle_tracks_loaded :: (task: *Http_Task) {
req := cast(*Shuffle_Fetch) task.user_data;
defer free(req);
if !task.response.ok {
log_error("shuffle fetch failed: status=%", task.response.status_code);
return;
}
ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response);
if !ok {
log_error("shuffle fetch: json parse failed");
return;
}
if parsed.Items.count == 0 {
log_warn("shuffle fetch: no tracks returned (mode=%)", req.mode);
return;
}
queue.shuffle_mode = req.mode;
queue_clear();
for parsed.Items {
t: Track;
t.id = copy_string(it.Id);
t.name = copy_string(it.Name);
t.album = copy_string(it.Album);
t.album_id = copy_string(it.AlbumId);
t.artist = copy_string(it.AlbumArtist);
t.artist_id = ifx it.AlbumArtists.count > 0
then copy_string(it.AlbumArtists[0].Id)
else copy_string(it.AlbumArtistId);
t.duration_ticks = it.RunTimeTicks;
t.index_number = it.IndexNumber;
t.is_favourite = it.UserData.IsFavorite;
queue_add(t);
}
queue_rebuild_shuffle();
if queue.shuffle_order.count == 0 {
log_warn("shuffle: no playable tracks after filtering");
return;
}
queue.current = queue.shuffle_order[0];
audio_play_track(queue.tracks[queue.current]);
app.current_view = .NOW_PLAYING;
log_info("shuffle: queued % tracks, starting '%'",
queue.tracks.count, queue.tracks[queue.current].name);
}

View File

@ -4,7 +4,7 @@
// //
FONT_DIR :: "data/fonts"; FONT_DIR :: "data/fonts";
FONT_FILE :: "OpenSans-BoldItalic.ttf"; FONT_FILE :: "bitfont.ttf";
init_fonts :: () { init_fonts :: () {
h := app.window_height; h := app.window_height;

View File

@ -1,13 +1,106 @@
// //
// 2000s album-skin theme. We start from the GetRect "Blood Vampire" / "Nimbus" // Custom player theme. Rebuilt every frame so it adapts to the album-art
// vibe but you can pick any of the bundled themes via app.current_theme. // palette automatically. Near-zero corner rounding throughout.
//
// Eventually we'll write a fully custom Overall_Theme here with chunky bevels,
// big rounding, and neon accents. For MVP we lean on the existing theme procs
// and let the visualizer shader carry the aesthetic.
// //
ui_theme_init :: () { ui_theme_init :: () {}
// Default_Themes.Blood_Vampire is the loudest stock theme — fits the brief.
app.current_theme = xx Default_Themes.Blood_Vampire; player_theme :: () -> Overall_Theme {
// Pull colours from the current palette, fall back to deep violet.
surface: Vector4;
border: Vector4;
interact: Vector4;
if app.palette_ready {
// bg → widget surface (slightly lightened so it reads as a surface)
p0 := app.palette[0];
surface = .{p0.x * 1.6 + 0.04, p0.y * 1.6 + 0.02, p0.z * 1.6 + 0.06, 1};
// highlight → interact colour
p1 := app.palette[1];
interact = .{p1.x * 0.75, p1.y * 0.75, p1.z * 0.75, 1};
// mid → border
p2 := app.palette[2];
border = .{p2.x * 0.45, p2.y * 0.45, p2.z * 0.45, 1};
} else {
surface = .{0.09, 0.05, 0.15, 1};
border = .{0.24, 0.09, 0.42, 1};
interact = .{0.52, 0.13, 0.78, 1};
}
text_lo :: Vector4.{0.75, 0.72, 0.90, 1}; // used internally by set_theme_from_base_colors
text_hi :: Vector4.{1.00, 1.00, 1.00, 1};
ROUNDING :: 0.0; // Fully squared off — hardware aesthetic.
result: Overall_Theme;
set_theme_from_base_colors(*result, surface, border, interact, text_lo, text_hi);
// Override the bluish text that set_theme_from_base_colors produces.
// Match the album-label style from now_playing_view: palette[3] lightened,
// or a pure neutral off-white when no palette is loaded.
body_text: Vector4;
if app.palette_ready {
p := app.palette[3];
body_text = .{p.x * 0.55 + 0.40, p.y * 0.55 + 0.40, p.z * 0.55 + 0.40, 1};
} else {
body_text = .{0.94, 0.94, 0.94, 1}; // neutral — no blue tint
}
WHITE :: Vector4.{1, 1, 1, 1};
result.label_theme.text_color = body_text;
result.text_color = body_text;
result.button_theme.text_color = body_text;
result.button_theme.text_color_over = WHITE;
result.background_color = ifx app.palette_ready
then Vector4.{app.palette[0].x * 0.9, app.palette[0].y * 0.9, app.palette[0].z * 0.9, 1}
else Vector4.{0.04, 0.02, 0.08, 1};
result.background_color_bright = surface;
result.button_theme.rectangle_shape.roundedness = ROUNDING;
{
using result.scrollable_region_theme;
region_background.shape.roundedness = ROUNDING;
scrollbar_background.shape.roundedness = ROUNDING;
scrollbar_background.color = result.background_color;
scrollbar_background.frame_color = border;
scrollbar_nib_theme.surface_color = border;
scrollbar_nib_theme.surface_color_over = interact;
scrollbar_nib_theme.surface_color_down = interact;
scrollbar_nib_theme.frame_color = border;
scrollbar_nib_theme.frame_color_over = interact;
scrollbar_nib_theme.rectangle_shape.roundedness = ROUNDING;
}
{
using result.slider_theme;
foreground.surface_color = interact;
foreground.surface_color_over = .{min(interact.x+0.12,1), min(interact.y+0.12,1), min(interact.z+0.12,1), 1};
foreground.surface_color_flash = foreground.surface_color_over;
foreground.surface_color_down = interact;
foreground.frame_color = .{interact.x*0.55, interact.y*0.55, interact.z*0.55, 1};
foreground.frame_color_over = interact;
foreground.text_color = text_hi;
foreground.rectangle_shape.roundedness = ROUNDING;
background.surface_color = result.background_color;
background.surface_color_over = result.background_color;
background.frame_color = border;
background.frame_color_over = border;
background.rectangle_shape.roundedness = ROUNDING;
}
{
using result.text_input_theme;
surface_color = .{surface.x * 0.7, surface.y * 0.7, surface.z * 0.7, 1};
surface_color_over = surface;
frame_color = border;
frame_color_over = interact;
rectangle_shape.roundedness = ROUNDING;
text_color = text_hi;
text_color_over = text_hi;
selection_color = interact;
}
return result;
} }

View File

@ -7,6 +7,8 @@
// //
draw_library_view :: () { draw_library_view :: () {
artist_alpha_jump();
w := cast(float) app.window_width; w := cast(float) app.window_width;
h := cast(float) app.window_height; h := cast(float) app.window_height;
@ -26,21 +28,38 @@ draw_library_header :: (x: float, y: float, w: float, h: float) {
label_theme := app.theme.label_theme; label_theme := app.theme.label_theme;
label_theme.font = app.title_font; label_theme.font = app.title_font;
label_theme.alignment = .Left; label_theme.alignment = .Left;
label_theme.text_color = .{1, 0.4, 0.8, 1}; label_theme.text_color = ifx app.palette_ready then app.palette[1] else Vector4.{1, 0.4, 0.8, 1};
label(get_rect(x + h * 0.4, y + h * 0.1, w * 0.5, h * 0.8), "PLAYER", *label_theme); label(get_rect(x + h * 0.4, y + h * 0.1, w * 0.5, h * 0.8), "CELICA", *label_theme);
button_theme := app.theme.button_theme; button_theme := app.theme.button_theme;
button_theme.font = app.button_font; button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center; button_theme.label_theme.alignment = .Center;
bw := w * 0.10;
bh := h * 0.5; bh := h * 0.5;
if button(get_rect(x + w - bw - h * 0.4, y + h * 0.25, bw, bh), "logout", *button_theme) { by := y + h * 0.25;
bw_sm := w * 0.08;
bw_lg := w * 0.12;
// Logout — far right
if button(get_rect(x + w - bw_sm - h * 0.4, by, bw_sm, bh), "logout", *button_theme, identifier=10) {
jellyfin_logout(*app.jellyfin); jellyfin_logout(*app.jellyfin);
free(app.jellyfin.password); free(app.jellyfin.password);
app.jellyfin.password = copy_string(""); app.jellyfin.password = copy_string("");
app.current_view = .LOGIN; app.current_view = .LOGIN;
} }
// Shuffle start buttons — queue current tracks and begin playing shuffled.
gap := bw_lg * 0.15;
sfav_x := x + w - bw_sm - bw_lg - h * 0.4 - gap;
sf_x := sfav_x - bw_lg - gap;
if button(get_rect(sfav_x, by, bw_lg, bh), "Shuffle Fav", *button_theme, identifier=12) {
library_start_global_shuffle(.FAVOURITES);
}
if button(get_rect(sf_x, by, bw_lg, bh), "Shuffle", *button_theme, identifier=11) {
library_start_global_shuffle(.ALL);
}
} }
draw_library_columns :: (x: float, y: float, w: float, h: float) { draw_library_columns :: (x: float, y: float, w: float, h: float) {
@ -77,8 +96,8 @@ draw_artists_column :: (x: float, y: float, w: float, h: float) {
active := app.library.selected_artist_id == it.id; active := app.library.selected_artist_id == it.id;
bt := button_theme; bt := button_theme;
if active { if active {
bt.surface_color = .{0.35, 0.10, 0.45, 1}; bt.surface_color = lib_sel_color();
bt.text_color = .{1, 1, 1, 1}; bt.text_color = lib_sel_text();
} }
thumb_size := row_h; thumb_size := row_h;
@ -119,8 +138,8 @@ draw_albums_column :: (x: float, y: float, w: float, h: float) {
active := app.library.selected_album_id == it.id; active := app.library.selected_album_id == it.id;
bt := button_theme; bt := button_theme;
if active { if active {
bt.surface_color = .{0.35, 0.10, 0.45, 1}; bt.surface_color = lib_sel_color();
bt.text_color = .{1, 1, 1, 1}; bt.text_color = lib_sel_text();
} }
thumb_size := row_h; thumb_size := row_h;
@ -161,8 +180,8 @@ draw_tracks_column :: (x: float, y: float, w: float, h: float) {
playing := app.current_stream && app.current_track.id == it.id; playing := app.current_stream && app.current_track.id == it.id;
bt := button_theme; bt := button_theme;
if playing { if playing {
bt.surface_color = .{0.10, 0.40, 0.55, 1}; bt.surface_color = lib_play_color();
bt.text_color = .{1, 1, 1, 1}; bt.text_color = lib_sel_text();
} }
label_text := tprint("%. %", it.index_number, it.name); label_text := tprint("%. %", it.index_number, it.name);
@ -172,6 +191,7 @@ draw_tracks_column :: (x: float, y: float, w: float, h: float) {
queue_add(tr); queue_add(tr);
if tr.id == it.id queue.current = idx; if tr.id == it.id queue.current = idx;
} }
if queue.shuffle_mode != .OFF queue_rebuild_shuffle();
audio_play_track(it); audio_play_track(it);
app.current_view = .NOW_PLAYING; app.current_view = .NOW_PLAYING;
} }
@ -185,7 +205,7 @@ draw_column_header :: (x: float, y: float, w: float, h: float, text: string) {
label_theme := app.theme.label_theme; label_theme := app.theme.label_theme;
label_theme.font = app.button_font; label_theme.font = app.button_font;
label_theme.alignment = .Left; label_theme.alignment = .Left;
label_theme.text_color = .{0.6, 0.9, 1.0, 1}; label_theme.text_color = ifx app.palette_ready then app.palette[2] else Vector4.{0.6, 0.9, 1.0, 1};
label(get_rect(x, y, w, h), text, *label_theme); label(get_rect(x, y, w, h), text, *label_theme);
} }
@ -231,10 +251,17 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) {
bw := h * 1.3; bw := h * 1.3;
bh := h * 0.55; bh := h * 0.55;
spacing := h * 0.15; spacing := h * 0.15;
cluster_w := bw * 4 + spacing * 3; cluster_w := bw * 5 + spacing * 4;
bx := x + w - cluster_w - h * 0.3; bx := x + w - cluster_w - h * 0.3;
by := y + h * 0.225; by := y + h * 0.225;
shuf_label := ifx queue.shuffle_mode == .OFF then "seq" else ifx queue.shuffle_mode == .ALL then "shuf" else "shuf*";
if button(get_rect(bx, by, bw, bh), shuf_label, *button_theme) {
if queue.shuffle_mode == .OFF queue_set_shuffle(.ALL);
else if queue.shuffle_mode == .ALL queue_set_shuffle(.FAVOURITES);
else queue_set_shuffle(.OFF);
}
bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), "<<", *button_theme) queue_prev(); if button(get_rect(bx, by, bw, bh), "<<", *button_theme) queue_prev();
bx += bw + spacing; bx += bw + spacing;
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE"; pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
@ -244,3 +271,70 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) {
bx += bw + spacing; bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), "now", *button_theme) app.current_view = .NOW_PLAYING; if button(get_rect(bx, by, bw, bh), "now", *button_theme) app.current_view = .NOW_PLAYING;
} }
#scope_file
// Scroll the artist column to the first artist whose name starts with the
// pressed letter. Works off TEXT_INPUT events so it respects the OS key map.
artist_alpha_jump :: () {
if !app.library.artists_loaded return;
for Input.events_this_frame {
if it.type != .TEXT_INPUT continue;
ch := it.utf32;
if ch >= 97 && ch <= 122 ch -= 32; // normalise to upper-case
if ch < 65 || ch > 90 continue; // ignore non-letters
row_h := app.row_font.character_height * 1.8;
for artist, idx: app.library.artists {
if artist.name.count == 0 continue;
name := artist_sort_name(artist.name);
first := cast(u32) name[0];
if first >= 97 && first <= 122 first -= 32;
if first == ch {
app.library.artists_scroll = cast(float) idx * row_h * 1.05;
break;
}
}
}
}
// Strip leading articles so "The Beatles" sorts/jumps as "Beatles".
artist_sort_name :: (name: string) -> string {
if begins_with_nocase(name, "the ") && name.count > 4 return slice(name, 4, name.count - 4);
if begins_with_nocase(name, "a ") && name.count > 2 return slice(name, 2, name.count - 2);
if begins_with_nocase(name, "an ") && name.count > 3 return slice(name, 3, name.count - 3);
return name;
}
begins_with_nocase :: (s: string, prefix: string) -> bool {
if s.count < prefix.count return false;
for i: 0..prefix.count-1 {
sc := s[i]; pc := prefix[i];
if sc >= #char "A" && sc <= #char "Z" sc += 32;
if sc != pc return false;
}
return true;
}
// Selection highlight — palette[1] darkened to read as a background fill.
lib_sel_color :: () -> Vector4 {
if !app.palette_ready return .{0.35, 0.10, 0.45, 1};
p := app.palette[1];
return .{p.x * 0.40, p.y * 0.40, p.z * 0.40, 1};
}
// Currently-playing highlight — palette[2] darkened, distinct from selection.
lib_play_color :: () -> Vector4 {
if !app.palette_ready return .{0.10, 0.40, 0.55, 1};
p := app.palette[2];
return .{p.x * 0.38, p.y * 0.38, p.z * 0.38, 1};
}
// Text on a highlighted row — bright version of palette[1].
lib_sel_text :: () -> Vector4 {
if !app.palette_ready return .{1, 1, 1, 1};
p := app.palette[1];
return .{min(p.x * 1.4 + 0.2, 1), min(p.y * 1.4 + 0.2, 1), min(p.z * 1.4 + 0.2, 1), 1};
}

View File

@ -1,186 +1,328 @@
// //
// Now playing — the marquee screen. Visualizer shader runs behind this in // Now playing — adapts to three layout modes based on the window aspect ratio:
// window.jai, so the foreground here is just track info, transport, and // TALL (aspect < 0.90) — portrait: art on top, info stacked below
// the seek/volume sliders. // NORMAL (0.90 1.90) — landscape: art left, info right
// // WIDE (aspect > 1.90) — ultra-wide: more padding, larger art
// Artist backdrop (if available) renders full-screen behind everything with
// a dark tint overlay so the UI remains readable.
// //
draw_now_playing_view :: () { draw_now_playing_view :: () {
w := cast(float) app.window_width; w := cast(float) app.window_width;
h := cast(float) app.window_height; h := cast(float) app.window_height;
k := h * 0.05;
has_track := app.current_stream != null || app.current_track.id.count > 0; has_track := app.current_stream != null || app.current_track.id.count > 0;
// draw_now_playing_backdrop(w, h, has_track);
// Full-screen artist backdrop (drawn first, behind everything).
// We tint it dark so the UI text remains readable. aspect := w / h;
// if aspect < 0.90 {
if has_track && app.current_track.artist_id.count > 0 { draw_now_playing_tall(w, h, has_track);
backdrop := image_request(app.current_track.artist_id, .BACKDROP); } else if aspect > 1.90 {
// Draw backdrop full-screen draw_now_playing_wide(w, h, has_track);
draw_image(0, 0, w, h, backdrop);
// Dark overlay: 60% black so the UI pops against busy backdrops
Simp.set_shader_for_color();
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 0.60});
} else { } else {
// No track playing — solid background draw_now_playing_normal(w, h, has_track);
Simp.set_shader_for_color();
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 1});
}
// Top-left: back to library.
{
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
if button(get_rect(k * 0.6, k * 0.6, w * 0.16, k), "library", *button_theme) {
app.current_view = .LIBRARY;
}
}
// Layout: art on the left, info column on the right.
art_size := min(w * 0.32, h * 0.55);
art_x := w * 0.06;
art_y := h * 0.12;
info_x := art_x + art_size + w * 0.04;
info_w := w - info_x - w * 0.06;
if has_track {
img := image_request(app.current_track.album_id, .LARGE);
draw_image(art_x, art_y, art_size, art_size, img);
} else {
// Placeholder slot so the layout doesn't shift on first launch.
Simp.set_shader_for_color();
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
}
// Track name (large).
text := ifx has_track app.current_track.name else "— silence —";
{
label_theme := app.theme.label_theme;
label_theme.font = app.title_font;
label_theme.alignment = .Left;
label_theme.text_color = .{1, 0.4, 0.8, 1};
r := get_rect(info_x, art_y, info_w, app.title_font.character_height * 1.4);
label(r, text, *label_theme);
}
if has_track {
label_theme := app.theme.label_theme;
label_theme.font = app.big_font;
label_theme.alignment = .Left;
label_theme.text_color = .{0.8, 0.8, 1, 1};
r := get_rect(info_x, art_y + app.title_font.character_height * 1.5, info_w, app.big_font.character_height * 1.4);
label(r, app.current_track.artist, *label_theme);
label_theme.font = app.body_font;
label_theme.text_color = .{0.6, 0.6, 0.8, 1};
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
label(r, app.current_track.album, *label_theme);
// Format chip.
label_theme.text_color = .{0.5, 0.9, 0.7, 1};
format_text := tprint("[%]", app.current_format);
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6 + app.body_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
label(r, format_text, *label_theme);
}
// Seek slider — replaces the old hand-drawn position strip.
if has_track && app.current_stream && app.current_stream.sound_data {
rate := cast(float) app.current_stream.sound_data.sampling_rate;
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
if total <= 0 total = max(cast(float) app.current_stream.play_cursor / rate + 1, 1);
// Each frame, mirror play_cursor → scrub_seconds before the slider
// draws. If the user drags, the slider mutates scrub_seconds in
// place; we read `changed` and seek.
app.scrub_seconds = clamp(cast(float) app.current_stream.play_cursor / rate, 0, total);
slider_theme := app.theme.slider_theme;
slider_theme.foreground.font = app.button_font;
slider_w := w * 0.7;
slider_h := k * 0.6;
slider_x := (w - slider_w) * 0.5;
slider_y := h * 0.66;
changed, _ := slider(
get_rect(slider_x, slider_y, slider_w, slider_h),
*app.scrub_seconds,
0.0, total, 1.0,
*slider_theme,
"", "s",
);
if changed {
audio_seek_seconds(app.scrub_seconds);
}
// Time labels under the slider.
time_theme := app.theme.label_theme;
time_theme.font = app.body_font;
time_theme.text_color = .{0.7, 0.7, 0.85, 1};
time_theme.alignment = .Left;
label(get_rect(slider_x, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(app.scrub_seconds), *time_theme);
time_theme.alignment = .Right;
label(get_rect(slider_x + slider_w * 0.5, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(total), *time_theme);
}
// Transport buttons cluster, centered.
button_theme := app.theme.button_theme;
button_theme.font = app.button_font;
button_theme.label_theme.alignment = .Center;
bw := k * 2.6;
bh := k * 1.6;
spacing := k * 0.4;
total_btn_w := bw * 3 + spacing * 2;
bx := (w - total_btn_w) * 0.5;
by := h * 0.78;
if button(get_rect(bx, by, bw, bh), "<<", *button_theme, identifier=1) queue_prev();
bx += bw + spacing;
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
if button(get_rect(bx, by, bw, bh), pp_label, *button_theme, identifier=2) audio_toggle_pause();
bx += bw + spacing;
if button(get_rect(bx, by, bw, bh), ">>", *button_theme, identifier=3) queue_next();
// Volume slider, bottom-right.
{
slider_theme := app.theme.slider_theme;
slider_theme.foreground.font = app.body_font;
vw := w * 0.18;
vh := k * 0.55;
vx := w - vw - k * 0.6;
vy := h - vh - k * 0.6;
before := app.master_volume;
slider(
get_rect(vx, vy, vw, vh),
*app.master_volume,
0.0, 1.0, 0.05,
*slider_theme,
"vol ", "",
identifier=99,
);
if before != app.master_volume {
// Persist on every change — config_save is cheap and the user
// expects volume to survive restarts.
config_save();
}
} }
} }
#scope_file #scope_file
// ── Shared backdrop ───────────────────────────────────────────────────────────
draw_now_playing_backdrop :: (w: float, h: float, has_track: bool) {
if has_track && app.current_track.artist_id.count > 0 {
backdrop := image_request(app.current_track.artist_id, .BACKDROP);
if backdrop && backdrop.failed {
backdrop = image_request(app.current_track.artist_id, .LARGE);
}
if backdrop && backdrop.loaded {
draw_image_cover(0, 0, w, h, backdrop, tint=Vector4.{1, 1, 1, 0.5});
}
}
// Visualizer on top of backdrop. Background quad at 50% so the artist
// image shows through, bars drawn fully opaque on top.
gfx_draw_visualizer_background(w, h, bg_alpha=0.5);
}
// ── Shared transport ─────────────────────────────────────────────────────────
draw_now_playing_transport :: (cx: float, by: float, bw: float, bh: float, sp: float, has_track: bool, font: *Simp.Dynamic_Font = null) {
bt := app.theme.button_theme;
bt.font = ifx font then font else app.button_font;
bt.label_theme.alignment = .Center;
total_btn_w := bw * 5 + sp * 4;
bx := cx - total_btn_w * 0.5;
shuf_label := ifx queue.shuffle_mode == .OFF then "seq" else ifx queue.shuffle_mode == .ALL then "shuf" else "shuf*";
if button(get_rect(bx, by, bw, bh), shuf_label, *bt, identifier=4) {
if queue.shuffle_mode == .OFF queue_set_shuffle(.ALL);
else if queue.shuffle_mode == .ALL queue_set_shuffle(.FAVOURITES);
else queue_set_shuffle(.OFF);
}
bx += bw + sp;
if button(get_rect(bx, by, bw, bh), "<<", *bt, identifier=1) queue_prev();
bx += bw + sp;
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
if button(get_rect(bx, by, bw, bh), pp_label, *bt, identifier=2) audio_toggle_pause();
bx += bw + sp;
if button(get_rect(bx, by, bw, bh), ">>", *bt, identifier=3) queue_next();
bx += bw + sp;
fav_bt := bt;
if has_track && app.current_track.is_favourite {
fav_bt.surface_color = .{0.6, 0.05, 0.25, 1};
fav_bt.text_color = .{1, 0.7, 0.8, 1};
}
fav_label := ifx (has_track && app.current_track.is_favourite) then "* fav" else "fav";
if button(get_rect(bx, by, bw, bh), fav_label, *fav_bt, identifier=6) && has_track {
jellyfin_toggle_favourite(app.current_track.id, app.current_track.is_favourite);
app.current_track.is_favourite = !app.current_track.is_favourite;
}
}
draw_now_playing_seek :: (slider_x: float, slider_y: float, slider_w: float, slider_h: float, has_track: bool) {
if !has_track || !app.current_stream || !app.current_stream.sound_data return;
rate := cast(float) app.current_stream.sound_data.sampling_rate;
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
if total <= 0 total = max(cast(float) app.current_stream.play_cursor / rate + 1, 1);
app.scrub_seconds = clamp(cast(float) app.current_stream.play_cursor / rate, 0, total);
st := make_palette_slider_theme(app.button_font);
changed, _ := slider(
get_rect(slider_x, slider_y, slider_w, slider_h),
*app.scrub_seconds, 0.0, total, 30.0, *st, "", "s",
);
if changed audio_seek_seconds(app.scrub_seconds);
col_album := now_playing_album_color();
time_lt := app.theme.label_theme;
time_lt.font = app.body_font;
time_lt.text_color = col_album;
time_lt.alignment = .Left;
label(get_rect(slider_x, slider_y + slider_h + 2, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(app.scrub_seconds), *time_lt);
time_lt.alignment = .Right;
label(get_rect(slider_x + slider_w * 0.5, slider_y + slider_h + 2, slider_w * 0.5, app.body_font.character_height * 1.3),
format_seconds(total), *time_lt);
}
draw_now_playing_volume :: (vx: float, vy: float, vw: float, vh: float) {
vst := make_palette_slider_theme(app.body_font);
before := app.master_volume;
slider(get_rect(vx, vy, vw, vh), *app.master_volume, 0.0, 1.0, 0.05, *vst, "vol ", "", identifier=99);
if before != app.master_volume config_save();
}
// ── NORMAL layout (landscape) ────────────────────────────────────────────────
draw_now_playing_normal :: (w: float, h: float, has_track: bool) {
k := h * 0.05;
{ // Back to library
bt := app.theme.button_theme;
bt.font = app.button_font;
bt.label_theme.alignment = .Center;
if button(get_rect(k * 0.6, k * 0.6, w * 0.16, k), "library", *bt)
app.current_view = .LIBRARY;
}
art_size := min(w * 0.32, h * 0.52);
art_x := w * 0.06;
art_y := h * 0.12;
info_x := art_x + art_size + w * 0.05;
info_w := w - info_x - w * 0.04;
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
else {
Simp.set_shader_for_color();
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);
draw_now_playing_seek(w * 0.14, h * 0.67, 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_volume(w - w * 0.17 - k * 0.5, h - k * 0.5 - k * 0.5, w * 0.17, k * 0.5);
}
// ── TALL layout (portrait) ───────────────────────────────────────────────────
draw_now_playing_tall :: (w: float, h: float, has_track: bool) {
// In tall/narrow windows, fonts are sized by HEIGHT which makes them huge.
// Use row_font for title and body_font for sub-text so everything fits.
pad := w * 0.05;
{
bt := app.theme.button_theme;
bt.font = app.body_font;
bt.label_theme.alignment = .Center;
if button(get_rect(pad * 0.8, pad * 0.4, w * 0.30, app.body_font.character_height * 1.6), "library", *bt)
app.current_view = .LIBRARY;
}
// Art — centered, capped so it doesn't dominate
art_size := min(w * 0.82, h * 0.38);
art_x := (w - art_size) * 0.5;
art_y := h * 0.07;
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
else {
Simp.set_shader_for_color();
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
}
// Running y cursor — everything stacks below art
y := art_y + art_size + h * 0.025;
// Info: row_font title, body_font sub — avoids the enormous h/8 title
y = draw_now_playing_info(pad, y, w - pad * 2, has_track, align_center=true,
title_font=app.row_font, sub_font=app.body_font);
y += h * 0.02;
// Seek slider
seek_h := app.body_font.character_height * 1.1;
draw_now_playing_seek(pad, y, w - pad * 2, seek_h, has_track);
y += seek_h + app.body_font.character_height * 1.4 + h * 0.025;
// Transport: use body_font so "PAUSE" fits in narrow buttons
bw := (w - pad * 2 - 4 * pad * 0.5) / 5.0; // 5 buttons filling the width
bh := bw * 0.55;
sp := pad * 0.5;
draw_now_playing_transport(w * 0.5, y, bw, bh, sp, has_track, font=app.body_font);
y += bh + h * 0.025;
// Volume
vw := w * 0.65;
draw_now_playing_volume((w - vw) * 0.5, y, vw, app.body_font.character_height * 1.1);
}
// ── WIDE layout (ultra-wide) ─────────────────────────────────────────────────
draw_now_playing_wide :: (w: float, h: float, has_track: bool) {
k := h * 0.05;
{ // Back to library
bt := app.theme.button_theme;
bt.font = app.button_font;
bt.label_theme.alignment = .Center;
if button(get_rect(k * 0.6, k * 0.6, w * 0.10, k), "library", *bt)
app.current_view = .LIBRARY;
}
// Art — left side, generous size
art_size := min(w * 0.28, h * 0.60);
art_x := w * 0.05;
art_y := (h - art_size) * 0.45;
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
else {
Simp.set_shader_for_color();
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
}
// Info — center column
info_x := art_x + art_size + w * 0.04;
info_w := w * 0.42;
draw_now_playing_info(info_x, art_y, info_w, has_track, align_center=false);
// Seek slider — under info
seek_y := art_y + art_size * 0.65;
draw_now_playing_seek(info_x, seek_y, info_w, k * 0.50, has_track);
// Transport centered under seek
bw := k * 2.2;
draw_now_playing_transport(info_x + info_w * 0.5, seek_y + k * 0.9, bw, k * 1.4, k * 0.3, has_track);
// Volume — bottom right
draw_now_playing_volume(w - w * 0.14 - k * 0.5, h - k * 0.5 - k * 0.5, w * 0.14, k * 0.5);
}
// ── Info text block ──────────────────────────────────────────────────────────
// Returns the y coordinate just below the last line of text.
draw_now_playing_info :: (x: float, y: float, w: float, has_track: bool, align_center: bool,
title_font: *Simp.Dynamic_Font = null, sub_font: *Simp.Dynamic_Font = null) -> float {
tf := ifx title_font then title_font else app.big_font;
sf := ifx sub_font then sub_font else app.row_font;
col_title := now_playing_title_color();
col_artist := now_playing_artist_color();
col_album := now_playing_album_color();
align := ifx align_center then Text_Alignment.Center else Text_Alignment.Left;
text := ifx has_track then app.current_track.name else "— silence —";
{
lt := app.theme.label_theme;
lt.font = tf; lt.alignment = align; lt.text_color = col_title;
label(get_rect(x, y, w, tf.character_height * 1.4), text, *lt);
}
if !has_track return y + tf.character_height * 1.4;
row_y := y + tf.character_height * 1.6;
{
lt := app.theme.label_theme;
lt.font = sf; lt.alignment = align; lt.text_color = col_artist;
label(get_rect(x, row_y, w, sf.character_height * 1.4), app.current_track.artist, *lt);
row_y += sf.character_height * 1.7;
}
{
lt := app.theme.label_theme;
lt.font = app.body_font; lt.alignment = align; lt.text_color = col_album;
label(get_rect(x, row_y, w, app.body_font.character_height * 1.4), app.current_track.album, *lt);
row_y += app.body_font.character_height * 1.5;
}
return row_y;
}
// ── Palette colour helpers ────────────────────────────────────────────────────
now_playing_title_color :: () -> Vector4 {
return ifx app.palette_ready then app.palette[1] else Vector4.{1.0, 0.4, 0.8, 1};
}
now_playing_artist_color :: () -> Vector4 {
if !app.palette_ready return .{0.8, 0.8, 1.0, 1};
p := app.palette[2];
return .{p.x * 0.65 + 0.35, p.y * 0.65 + 0.35, p.z * 0.65 + 0.35, 1};
}
now_playing_album_color :: () -> Vector4 {
if !app.palette_ready return .{0.6, 0.6, 0.8, 1};
p := app.palette[3];
return .{p.x * 0.55 + 0.35, p.y * 0.55 + 0.35, p.z * 0.55 + 0.35, 1};
}
// ── Slider theme ─────────────────────────────────────────────────────────────
make_palette_slider_theme :: (font: *Simp.Dynamic_Font) -> Slider_Theme {
hi := ifx app.palette_ready then app.palette[1] else Vector4.{0.0, 0.65, 0.45, 1};
dark := ifx app.palette_ready
then Vector4.{app.palette[0].x * 1.3, app.palette[0].y * 1.3, app.palette[0].z * 1.3, 1}
else Vector4.{0.0, 0.10, 0.06, 1};
hi2 := Vector4.{min(hi.x + 0.12, 1), min(hi.y + 0.12, 1), min(hi.z + 0.12, 1), 1};
dk2 := Vector4.{dark.x * 1.6, dark.y * 1.6, dark.z * 1.6, 1};
st: Slider_Theme;
st.foreground.surface_color = hi;
st.foreground.surface_color_over = hi2;
st.foreground.surface_color_flash = hi2;
st.foreground.surface_color_down = hi;
st.foreground.frame_color = Vector4.{hi.x * 0.55, hi.y * 0.55, hi.z * 0.55, 1};
st.foreground.frame_color_over = hi;
st.foreground.text_color = .{1, 1, 1, 1};
st.foreground.font = font;
st.background.surface_color = dark;
st.background.surface_color_over = dark;
st.background.frame_color = dk2;
st.background.frame_color_over = dk2;
return st;
}
format_seconds :: (s: float) -> string { format_seconds :: (s: float) -> string {
if !(s >= 0) return "0:00"; // catches negatives and NaN if !(s >= 0) return "0:00";
total := cast(int) s; total := cast(int) s;
m := total / 60; m := total / 60;
sec := total - m * 60; sec := total - m * 60;