work
This commit is contained in:
parent
c8c69366b5
commit
19dc85821f
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
.build/
|
||||
build/player
|
||||
build/player.dSYM/
|
||||
18
ai/todo.md
18
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
|
||||
|
||||
BIN
build/player
BIN
build/player
Binary file not shown.
BIN
data/.DS_Store
vendored
Normal file
BIN
data/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
data/fonts/bitfont.ttf
Normal file
BIN
data/fonts/bitfont.ttf
Normal file
Binary file not shown.
BIN
modules/audio_decoders/macos/decoders.a
Normal file
BIN
modules/audio_decoders/macos/decoders.a
Normal file
Binary file not shown.
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
#load "shaders.jai";
|
||||
#load "images.jai";
|
||||
#load "palette.jai";
|
||||
|
||||
165
src/gfx/palette.jai
Normal file
165
src/gfx/palette.jai
Normal 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};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
22
src/jellyfin/favourites.jai
Normal file
22
src/jellyfin/favourites.jai
Normal 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)));
|
||||
}
|
||||
}
|
||||
@ -4,3 +4,5 @@
|
||||
#load "library.jai";
|
||||
#load "images.jai";
|
||||
#load "stream.jai";
|
||||
#load "favourites.jai";
|
||||
#load "shuffle.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;
|
||||
|
||||
83
src/jellyfin/shuffle.jai
Normal file
83
src/jellyfin/shuffle.jai
Normal 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);
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
|
||||
FONT_DIR :: "data/fonts";
|
||||
FONT_FILE :: "OpenSans-BoldItalic.ttf";
|
||||
FONT_FILE :: "bitfont.ttf";
|
||||
|
||||
init_fonts :: () {
|
||||
h := app.window_height;
|
||||
|
||||
111
src/ui/theme.jai
111
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;
|
||||
}
|
||||
|
||||
@ -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};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user