diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b03f4c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.build/ +build/player +build/player.dSYM/ diff --git a/ai/todo.md b/ai/todo.md index 1f371f6..eeeaaff 100644 --- a/ai/todo.md +++ b/ai/todo.md @@ -26,10 +26,14 @@ ## Next likely tasks (in rough order) -1. Free the decoded buffer when the stream ends (fix the leak) -2. Fetch primary image per album, decode with stb_image, upload as `Simp.Texture`, render in album rows + as backdrop on now-playing -3. Real FFT (dr_libs author's work? or kissfft) hooked to Sound_Player's mixed output buffer -4. Custom GLSL fragment shader for the visualizer (replaces the bar stack) -5. Album-level "play all" button in the album column -6. Real per-stream pause + seek -7. Search bar above artists column +1. ~~Free the decoded buffer when the stream ends (fix the leak)~~ — done via Track_Sound wrapper + release_asset callback. +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. +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 ...`. +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. +4. **Cover-art palette theming** — on track change, sample the album art texture (already loaded as a `Simp.Texture`) to extract 3–4 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. +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. +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 diff --git a/build/player b/build/player index d0ed206..c12033d 100755 Binary files a/build/player and b/build/player differ diff --git a/data/.DS_Store b/data/.DS_Store new file mode 100644 index 0000000..4a694af Binary files /dev/null and b/data/.DS_Store differ diff --git a/data/fonts/bitfont.ttf b/data/fonts/bitfont.ttf new file mode 100644 index 0000000..c0a046a Binary files /dev/null and b/data/fonts/bitfont.ttf differ diff --git a/modules/audio_decoders/macos/decoders.a b/modules/audio_decoders/macos/decoders.a new file mode 100644 index 0000000..e6d2e9a Binary files /dev/null and b/modules/audio_decoders/macos/decoders.a differ diff --git a/src/audio/analysis.jai b/src/audio/analysis.jai index 9445019..39cb7cb 100644 --- a/src/audio/analysis.jai +++ b/src/audio/analysis.jai @@ -1,21 +1,17 @@ // // Real spectrum analysis driving the visualizer. // -// Each frame we pull FFT_SIZE PCM frames around the active stream's -// play_cursor, mix to mono, window with Hann, FFT, then bin the magnitudes -// into SPECTRUM_BINS log-spaced bands. The result lives in `app.spectrum`, -// where `gfx/shaders.jai` reads it for the bar visualizer. +// Since we now play OGG_COMPRESSED (Sound_Player has no public PCM output), +// we maintain a separate stb_vorbis decoder in app.analysis_vorbis. Each +// frame we seek it to the current play_cursor position and decode FFT_SIZE +// samples for the FFT. Forward seeks (normal playback) are near-instant since +// they just continue from the current bitstream position. // -// Behaviour at edges: -// - no stream playing → bars decay smoothly to zero -// - 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.) +// play_cursor is in "virtual 44100 Hz sample" units (Sound_Player default); +// we convert to OGG sample position using the vorbis file's actual rate. // -MIN_VISUAL_FREQ :: 30.0; // skip DC and very-low rumble bins +MIN_VISUAL_FREQ :: 30.0; update_audio_analysis :: () { fft_init(); @@ -25,45 +21,57 @@ update_audio_analysis :: () { return; } - sd := app.current_stream.sound_data; - if sd.type != Sound.Sound_Data.Kind.LINEAR_SAMPLE_ARRAY { + if !app.analysis_vorbis { decay_spectrum(0.10); return; } - nchannels := cast(s64) sd.nchannels; - if nchannels < 1 { - 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 + 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; } - // Mix down to mono and apply window. - samples := sd.samples; - inv_chan := 1.0 / cast(float) nchannels; + // 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; + 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); + return; + } + + // Mix to mono and apply Hann window. + inv_chan := 1.0 / cast(float) nch; 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; - for ch: 0..nchannels-1 sum += cast(float) samples[idx + ch]; - mono := sum * inv_chan / 32768.0; // s16 → [-1, 1] + for ch: 0..nch-1 sum += cast(float) analysis_pcm_buf[k * nch + ch]; + mono := sum * inv_chan / 32768.0; fft_re[k] = mono * fft_window[k]; fft_im[k] = 0; } fft(); - // Bin into log-spaced bands. - rate := cast(float) sd.sampling_rate; + rate := ogg_rate; nyquist := rate * 0.5; log_lo := log(MIN_VISUAL_FREQ); log_hi := log(nyquist); @@ -85,16 +93,11 @@ update_audio_analysis :: () { 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 = sqrt(v); 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; app.spectrum[b] = lerp(prev, v, rate_lerp); } @@ -102,6 +105,8 @@ update_audio_analysis :: () { #scope_file +analysis_pcm_buf: [FFT_SIZE * 2] s16; + decay_spectrum :: (rate: float) { for 0..SPECTRUM_BINS-1 { app.spectrum[it] = lerp(app.spectrum[it], 0, rate); diff --git a/src/audio/player.jai b/src/audio/player.jai index b656af5..75f63e6 100644 --- a/src/audio/player.jai +++ b/src/audio/player.jai @@ -1,11 +1,12 @@ // -// Audio playback. The track download is async — `audio_play_track` returns -// immediately after queueing the request; when the bytes arrive we decode -// them and start a Sound_Player stream. +// Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes +// everything server-side) and hands it to Sound_Player's native OGG path. // -// Pause is REAL: setting `current_rate = desired_rate = 0` freezes the -// stream's play_cursor and (with `inaudible = true`) silences output. So -// while paused the seek bar doesn't drift and auto-advance can't fire. +// OGG_COMPRESSED streams from a decoder rather than decoding the full PCM +// into memory, so there is no large heap buffer to manage — Sound_Player +// 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) { @@ -14,12 +15,17 @@ audio_play_track :: (track: Track) { pending := New(Track); pending.* = clone_track(track); - // /universal lets the server pick the best format, transcoding to mp3 - // when the original isn't in our `container` list. Without this, Opus / - // M4A / AAC / WMA / ALAC files come back as bytes we can't decode. + // Ask Jellyfin to transcode everything to OGG Vorbis 192 kbps. + // We pin audioSampleRate to Sound_Player's actual output device rate so + // the OGG file's sample rate matches what Sound_Player expects when + // calculating desired_rate (= sound_data.sampling_rate / output_rate). + // Without this, Jellyfin defaults to 48000 Hz on many systems while + // sound_data.sampling_rate stays at 44100 → plays at 91.9% speed. + output_hz := Sound.get_audio_sampling_rate(); + if output_hz <= 0 output_hz = 44100; path := tprint( - "/Audio/%/universal?container=mp3,flac,ogg,wav&audioCodec=mp3&maxStreamingBitrate=320000&userId=%&deviceId=%&api_key=%", - track.id, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token, + "/Audio/%/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&audioSampleRate=%&audioChannels=2&userId=%&deviceId=%&api_key=%", + track.id, output_hz, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token, ); log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks); http_submit("GET", path, on_done=on_track_downloaded, user_data=pending); @@ -46,7 +52,7 @@ audio_is_paused :: () -> bool { audio_seek_seconds :: (seconds: float) { if !app.current_stream || !app.current_stream.sound_data return; - rate := cast(float64) app.current_stream.sound_data.sampling_rate; + rate := cast(float64) app.current_stream.sound_data.sampling_rate; target := cast(float64) seconds * rate; if target < 0 target = 0; app.current_stream.play_cursor = target; @@ -58,7 +64,23 @@ stop_current_stream :: () { 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) { + free(data.buffer); + free(data.name); + free(data); + if stream && stream.entity_id == app.track_entity_id { app.track_finished = true; app.current_stream = null; @@ -72,8 +94,10 @@ clone_track :: (t: Track) -> Track { out.album = copy_string(t.album); out.album_id = copy_string(t.album_id); out.artist = copy_string(t.artist); + out.artist_id = copy_string(t.artist_id); out.duration_ticks = t.duration_ticks; out.index_number = t.index_number; + out.is_favourite = t.is_favourite; return out; } @@ -83,6 +107,7 @@ free_track :: (t: *Track) { free(t.album); free(t.album_id); free(t.artist); + free(t.artist_id); t.* = .{}; } @@ -97,25 +122,36 @@ on_track_downloaded :: (task: *Http_Task) { return; } + // Transfer ownership of the bytes to Sound_Data.buffer. bytes := task.response.body; task.response.body = ""; - sd, format, ok := decode_audio(bytes, pending.name); - if !ok { - log_error("audio decode failed for '%'", pending.name); + sd := Sound.load_audio_data(pending.name, bytes); + if !sd.loaded { + log_error("audio: OGG decode failed for '%'", pending.name); free(bytes); return; } + data := New(Sound.Sound_Data); 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(); free_track(*app.current_track); app.current_track = clone_track(pending.*); - app.current_format = format; - - // New track always starts un-paused; user clicking Next/Prev or - // auto-advance both want fresh playback state. + app.current_format = .OGG; app.paused = false; app.track_entity_id += 1; @@ -125,5 +161,6 @@ on_track_downloaded :: (task: *Http_Task) { app.current_stream = stream; 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); } diff --git a/src/audio/queue.jai b/src/audio/queue.jai index fcb5893..e17e45c 100644 --- a/src/audio/queue.jai +++ b/src/audio/queue.jai @@ -1,36 +1,117 @@ // -// Naive play queue. Linear array, current index. Shuffle/repeat punted. -// audio_play_track is now async (returns void); the queue just fires the -// download and trusts it to land. +// Play queue with optional shuffle. +// +// 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 { - tracks: [..] Track; - current: int = -1; + tracks: [..] Track; + 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_clear :: () { array_reset(*queue.tracks); - queue.current = -1; + array_reset(*queue.shuffle_order); + queue.current = -1; + queue.shuffle_pos = -1; } queue_add :: (track: 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 { if index < 0 || index >= queue.tracks.count return false; 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]); return true; } 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); } 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); } diff --git a/src/core/app.jai b/src/core/app.jai index 3f23c61..6c8c3a5 100644 --- a/src/core/app.jai +++ b/src/core/app.jai @@ -49,6 +49,16 @@ App :: struct { // Per-frame audio analysis used by the visualizer shader. 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: Library_State; } @@ -62,7 +72,7 @@ app_init :: () { 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); init_fonts(); diff --git a/src/core/imports.jai b/src/core/imports.jai index 91b2063..80d5e74 100644 --- a/src/core/imports.jai +++ b/src/core/imports.jai @@ -22,3 +22,5 @@ Sound :: #import "Sound_Player"; Jaison :: #import "Jaison"; Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true); Audio_Decoders :: #import "audio_decoders"; +Stb_Image :: #import "stb_image"; +Stb_Vorbis :: #import "stb_vorbis"; diff --git a/src/core/window.jai b/src/core/window.jai index eae56a7..63d89a9 100644 --- a/src/core/window.jai +++ b/src/core/window.jai @@ -45,8 +45,7 @@ run_main_loop :: () { } draw_one_frame :: () { - proc := default_theme_procs[app.current_theme]; - app.theme = proc(); + app.theme = player_theme(); set_default_theme(app.theme); bg := app.theme.background_color; @@ -55,8 +54,9 @@ draw_one_frame :: () { x, y, w, h := get_dimensions(app.window, true); ui_per_frame_update(app.window, w, h, app.current_time); - // The visualizer shader runs *behind* the UI for every view that wants it. - if app.current_view != .LOGIN { + // Library: visualizer behind the UI. Now-playing: visualizer is drawn + // 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); } diff --git a/src/gfx/images.jai b/src/gfx/images.jai index 3fd8bfa..f37f0ad 100644 --- a/src/gfx/images.jai +++ b/src/gfx/images.jai @@ -82,7 +82,13 @@ image_request :: (item_id: string, size: Image_Size) -> *Image { req.image = img; req.id = copy_string(item_id); req.size = size; - array_add(*image_pending, req); + // 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); + } return img; } @@ -106,6 +112,7 @@ image_pump :: () { // Do NOT increment i — the array shifted. } else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES { 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); image_in_flight += 1; 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}) { if img && img.loaded { Simp.set_shader_for_images(*img.texture); @@ -192,20 +232,30 @@ consume_disk_hit :: (req: *Image_Request, disk_path: string) { img := req.image; img.loading = false; + log_info("image: disk hit id=% size=%", req.id, req.size); + bytes, ok := read_entire_file(disk_path, log_errors=false); if !ok { + log_warn("image: disk read failed id=% size=%", req.id, req.size); img.failed = true; return; } defer free(bytes); + if req.size == .LARGE palette_extract(req.id, bytes); + buf: [] u8 = ---; buf.data = bytes.data; buf.count = bytes.count; 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; return; } + log_info("image: disk loaded id=% size=% %x%", req.id, req.size, img.texture.width, img.texture.height); img.loaded = true; } @@ -219,6 +269,8 @@ on_image_response :: (task: *Http_Task) { img.loading = false; 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; return; } @@ -229,12 +281,17 @@ on_image_response :: (task: *Http_Task) { disk_path := image_disk_path(req.id, req.size); write_entire_file(disk_path, task.response.body); + if req.size == .LARGE palette_extract(req.id, task.response.body); + body_bytes: [] u8 = ---; body_bytes.data = task.response.body.data; body_bytes.count = task.response.body.count; 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; 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; } diff --git a/src/gfx/index.jai b/src/gfx/index.jai index 1688d1b..789ecc1 100644 --- a/src/gfx/index.jai +++ b/src/gfx/index.jai @@ -1,2 +1,3 @@ #load "shaders.jai"; #load "images.jai"; +#load "palette.jai"; diff --git a/src/gfx/palette.jai b/src/gfx/palette.jai new file mode 100644 index 0000000..d3e7e60 --- /dev/null +++ b/src/gfx/palette.jai @@ -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}; +} diff --git a/src/gfx/shaders.jai b/src/gfx/shaders.jai index f064cee..e21b0c9 100644 --- a/src/gfx/shaders.jai +++ b/src/gfx/shaders.jai @@ -12,27 +12,28 @@ gfx_init :: () { // 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(); - Simp.set_shader_for_color(); 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_bins := SPECTRUM_BINS / 6; if bass_bins < 1 bass_bins = 1; for 0..bass_bins-1 bass += app.spectrum[it]; bass /= cast(float) bass_bins; - base := Vector4.{ - 0.04 + 0.10 * bass, - 0.02 + 0.04 * bass, - 0.10 + 0.18 * bass, - 1, - }; - Simp.immediate_quad(0, 0, w, h, base); + { + base: Vector4; + if app.palette_ready { + p := app.palette[0]; + 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); + } // Mirrored bars from the horizontal mid-line — bass on the left, treble // 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; 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.x = min(1.0, tip.x + 0.4); - tip.y = min(1.0, tip.y + 0.4); - tip.z = min(1.0, tip.z + 0.4); + tip.x = min(1.0, tip.x + 0.35); + tip.y = min(1.0, tip.y + 0.35); + tip.z = min(1.0, tip.z + 0.35); // Top half (going up). 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 - // 2000s-skin vibe. + // Center mirror line — a thin neon glow across the middle. { - line_h := 1.5; - line_col := Vector4.{1.0, 0.4, 0.8, 0.5 + 0.3 * bass}; + line_h := 1.5; + line_col := ifx app.palette_ready then app.palette[1] else Vector4.{1.0, 0.4, 0.8, 1}; + line_col.w = 0.5 + 0.3 * bass; Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col); } } diff --git a/src/jellyfin/favourites.jai b/src/jellyfin/favourites.jai new file mode 100644 index 0000000..06a0878 --- /dev/null +++ b/src/jellyfin/favourites.jai @@ -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))); + } +} diff --git a/src/jellyfin/index.jai b/src/jellyfin/index.jai index ce521c3..564ea95 100644 --- a/src/jellyfin/index.jai +++ b/src/jellyfin/index.jai @@ -4,3 +4,5 @@ #load "library.jai"; #load "images.jai"; #load "stream.jai"; +#load "favourites.jai"; +#load "shuffle.jai"; diff --git a/src/jellyfin/library.jai b/src/jellyfin/library.jai index 0edae95..d88f5bf 100644 --- a/src/jellyfin/library.jai +++ b/src/jellyfin/library.jai @@ -30,6 +30,7 @@ Track :: struct { artist_id: string; duration_ticks: s64; index_number: int; + is_favourite: bool; } Library_State :: struct { @@ -60,13 +61,26 @@ Library_State :: struct { #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 { Id: string; Name: string; Album: string; AlbumId: string; AlbumArtist: string; - AlbumArtistId: string; + AlbumArtistId: string; // populated for album items + AlbumArtists: [..] Artist_Ref; // populated for track items + UserData: User_Data_Dto; RunTimeTicks: s64; IndexNumber: int; } @@ -215,9 +229,12 @@ on_tracks_loaded :: (task: *Http_Task) { t.album = copy_string(it.Album); t.album_id = copy_string(it.AlbumId); 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.index_number = it.IndexNumber; + t.is_favourite = it.UserData.IsFavorite; array_add(*app.library.tracks, t); } app.library.tracks_loaded = true; diff --git a/src/jellyfin/shuffle.jai b/src/jellyfin/shuffle.jai new file mode 100644 index 0000000..b961f8b --- /dev/null +++ b/src/jellyfin/shuffle.jai @@ -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); +} diff --git a/src/ui/fonts.jai b/src/ui/fonts.jai index 06dae89..7420074 100644 --- a/src/ui/fonts.jai +++ b/src/ui/fonts.jai @@ -4,7 +4,7 @@ // FONT_DIR :: "data/fonts"; -FONT_FILE :: "OpenSans-BoldItalic.ttf"; +FONT_FILE :: "bitfont.ttf"; init_fonts :: () { h := app.window_height; diff --git a/src/ui/theme.jai b/src/ui/theme.jai index ec39ac6..45eea05 100644 --- a/src/ui/theme.jai +++ b/src/ui/theme.jai @@ -1,13 +1,106 @@ // -// 2000s album-skin theme. We start from the GetRect "Blood Vampire" / "Nimbus" -// vibe but you can pick any of the bundled themes via app.current_theme. -// -// 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. +// Custom player theme. Rebuilt every frame so it adapts to the album-art +// palette automatically. Near-zero corner rounding throughout. // -ui_theme_init :: () { - // Default_Themes.Blood_Vampire is the loudest stock theme — fits the brief. - app.current_theme = xx Default_Themes.Blood_Vampire; +ui_theme_init :: () {} + +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; } diff --git a/src/ui/views/library_view.jai b/src/ui/views/library_view.jai index aefe7cf..6c8832f 100644 --- a/src/ui/views/library_view.jai +++ b/src/ui/views/library_view.jai @@ -7,6 +7,8 @@ // draw_library_view :: () { + artist_alpha_jump(); + w := cast(float) app.window_width; 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.font = app.title_font; label_theme.alignment = .Left; - label_theme.text_color = .{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_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), "CELICA", *label_theme); button_theme := app.theme.button_theme; button_theme.font = app.button_font; button_theme.label_theme.alignment = .Center; - bw := w * 0.10; 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); free(app.jellyfin.password); app.jellyfin.password = copy_string(""); 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) { @@ -77,8 +96,8 @@ draw_artists_column :: (x: float, y: float, w: float, h: float) { active := app.library.selected_artist_id == it.id; bt := button_theme; if active { - bt.surface_color = .{0.35, 0.10, 0.45, 1}; - bt.text_color = .{1, 1, 1, 1}; + bt.surface_color = lib_sel_color(); + bt.text_color = lib_sel_text(); } 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; bt := button_theme; if active { - bt.surface_color = .{0.35, 0.10, 0.45, 1}; - bt.text_color = .{1, 1, 1, 1}; + bt.surface_color = lib_sel_color(); + bt.text_color = lib_sel_text(); } 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; bt := button_theme; if playing { - bt.surface_color = .{0.10, 0.40, 0.55, 1}; - bt.text_color = .{1, 1, 1, 1}; + bt.surface_color = lib_play_color(); + bt.text_color = lib_sel_text(); } 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); if tr.id == it.id queue.current = idx; } + if queue.shuffle_mode != .OFF queue_rebuild_shuffle(); audio_play_track(it); 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.font = app.button_font; 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); } @@ -231,10 +251,17 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) { bw := h * 1.3; bh := h * 0.55; spacing := h * 0.15; - cluster_w := bw * 4 + spacing * 3; + cluster_w := bw * 5 + spacing * 4; bx := x + w - cluster_w - h * 0.3; 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(); bx += bw + spacing; 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; 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}; +} diff --git a/src/ui/views/now_playing_view.jai b/src/ui/views/now_playing_view.jai index 13d7251..aa2d5e3 100644 --- a/src/ui/views/now_playing_view.jai +++ b/src/ui/views/now_playing_view.jai @@ -1,186 +1,328 @@ // -// Now playing — the marquee screen. Visualizer shader runs behind this in -// window.jai, so the foreground here is just track info, transport, and -// the seek/volume sliders. -// -// Artist backdrop (if available) renders full-screen behind everything with -// a dark tint overlay so the UI remains readable. +// Now playing — adapts to three layout modes based on the window aspect ratio: +// TALL (aspect < 0.90) — portrait: art on top, info stacked below +// NORMAL (0.90 – 1.90) — landscape: art left, info right +// WIDE (aspect > 1.90) — ultra-wide: more padding, larger art // draw_now_playing_view :: () { w := cast(float) app.window_width; h := cast(float) app.window_height; - k := h * 0.05; has_track := app.current_stream != null || app.current_track.id.count > 0; - // - // Full-screen artist backdrop (drawn first, behind everything). - // We tint it dark so the UI text remains readable. - // - if has_track && app.current_track.artist_id.count > 0 { - backdrop := image_request(app.current_track.artist_id, .BACKDROP); - // Draw backdrop full-screen - 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}); + draw_now_playing_backdrop(w, h, has_track); + + aspect := w / h; + if aspect < 0.90 { + draw_now_playing_tall(w, h, has_track); + } else if aspect > 1.90 { + draw_now_playing_wide(w, h, has_track); } else { - // No track playing — solid background - 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(); - } + draw_now_playing_normal(w, h, has_track); } } #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 { - if !(s >= 0) return "0:00"; // catches negatives and NaN + if !(s >= 0) return "0:00"; total := cast(int) s; m := total / 60; sec := total - m * 60;