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)
|
## Next likely tasks (in rough order)
|
||||||
|
|
||||||
1. Free the decoded buffer when the stream ends (fix the leak)
|
1. ~~Free the decoded buffer when the stream ends (fix the leak)~~ — done via Track_Sound wrapper + release_asset callback.
|
||||||
2. Fetch primary image per album, decode with stb_image, upload as `Simp.Texture`, render in album rows + as backdrop on now-playing
|
2. **Transcode everything to OGG via Jellyfin** — change the stream URL to `/Audio/{id}/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&userId=...`. Jellyfin re-encodes anything exotic; Sound_Player handles OGG natively via stb_vorbis as `OGG_COMPRESSED` (streams from a decoder, no full PCM buffer). This kills the memory leak for real, lets us delete the entire `audio_decoders` module (dr_mp3/dr_flac), and gives consistent 192 kbps quality regardless of source format. The Track_Sound/release_asset machinery can be simplified back to a plain `Sound_Data`. Only wrinkle: if the Jellyfin server is slow to transcode, first-play latency increases — acceptable trade-off.
|
||||||
3. Real FFT (dr_libs author's work? or kissfft) hooked to Sound_Player's mixed output buffer
|
2. **Shuffle mode + shuffle-favourites mode** — shuffle toggle in the now-playing transport bar; a second mode that restricts the shuffle pool to favourited tracks only. Jellyfin favourite status lives on the item (`UserData.IsFavorite`); toggling calls `POST /Users/{uid}/FavoriteItems/{id}` / `DELETE ...`.
|
||||||
4. Custom GLSL fragment shader for the visualizer (replaces the bar stack)
|
3. **Favourite button** — heart/star button on now-playing screen that shows current favourite state and toggles it. Read `IsFavorite` from track JSON, persist optimistically, sync with Jellyfin.
|
||||||
5. Album-level "play all" button in the album column
|
4. **Cover-art palette theming** — on track change, sample the album art texture (already loaded as a `Simp.Texture`) to extract 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.
|
||||||
6. Real per-stream pause + seek
|
5. **New default theme** — less corner rounding, richer/darker base colours (near-black backgrounds, jewel-toned accents), tighter padding. Replace or supplement the current GetRect theme proc.
|
||||||
7. Search bar above artists column
|
6. ~~**Artist list alpha-jump**~~ — done. TEXT_INPUT events in `draw_library_view` → linear scan → set `artists_scroll`.
|
||||||
|
6. Custom GLSL fragment shader for the visualizer (replaces the bar stack)
|
||||||
|
7. Album-level "play all" button in the album column
|
||||||
|
8. Real per-stream pause + seek
|
||||||
|
9. Search bar above artists column
|
||||||
|
|||||||
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.
|
// Real spectrum analysis driving the visualizer.
|
||||||
//
|
//
|
||||||
// Each frame we pull FFT_SIZE PCM frames around the active stream's
|
// Since we now play OGG_COMPRESSED (Sound_Player has no public PCM output),
|
||||||
// play_cursor, mix to mono, window with Hann, FFT, then bin the magnitudes
|
// we maintain a separate stb_vorbis decoder in app.analysis_vorbis. Each
|
||||||
// into SPECTRUM_BINS log-spaced bands. The result lives in `app.spectrum`,
|
// frame we seek it to the current play_cursor position and decode FFT_SIZE
|
||||||
// where `gfx/shaders.jai` reads it for the bar visualizer.
|
// samples for the FFT. Forward seeks (normal playback) are near-instant since
|
||||||
|
// they just continue from the current bitstream position.
|
||||||
//
|
//
|
||||||
// Behaviour at edges:
|
// play_cursor is in "virtual 44100 Hz sample" units (Sound_Player default);
|
||||||
// - no stream playing → bars decay smoothly to zero
|
// we convert to OGG sample position using the vorbis file's actual rate.
|
||||||
// - paused (play_cursor frozen) → spectrum still decays so the screen
|
|
||||||
// visibly stops reacting
|
|
||||||
// - OGG / non-PCM data → we don't have direct sample access, so spectrum
|
|
||||||
// decays. (All our /universal-fetched tracks decode through dr_libs
|
|
||||||
// into LINEAR_SAMPLE_ARRAY anyway, so this rarely fires in practice.)
|
|
||||||
//
|
//
|
||||||
|
|
||||||
MIN_VISUAL_FREQ :: 30.0; // skip DC and very-low rumble bins
|
MIN_VISUAL_FREQ :: 30.0;
|
||||||
|
|
||||||
update_audio_analysis :: () {
|
update_audio_analysis :: () {
|
||||||
fft_init();
|
fft_init();
|
||||||
@ -25,45 +21,57 @@ update_audio_analysis :: () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !app.analysis_vorbis {
|
||||||
|
decay_spectrum(0.10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vorbis := cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis;
|
||||||
|
info := Stb_Vorbis.stb_vorbis_get_info(vorbis);
|
||||||
|
if info.sample_rate == 0 || info.channels == 0 {
|
||||||
|
decay_spectrum(0.10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert play_cursor to real elapsed seconds, then to OGG sample position.
|
||||||
|
// play_cursor advances at play_rate * current_rate per second, where
|
||||||
|
// current_rate = play_rate / ogg_rate (set by make_stream). So:
|
||||||
|
// time_s = play_cursor / (play_rate * current_rate)
|
||||||
|
// ogg_pos = time_s * ogg_rate
|
||||||
sd := app.current_stream.sound_data;
|
sd := app.current_stream.sound_data;
|
||||||
if sd.type != Sound.Sound_Data.Kind.LINEAR_SAMPLE_ARRAY {
|
play_rate := cast(float) sd.sampling_rate; // 44100 default for OGG_COMPRESSED
|
||||||
|
ogg_rate := cast(float) info.sample_rate; // actual OGG sample rate
|
||||||
|
current_rate := app.current_stream.current_rate;
|
||||||
|
if current_rate <= 0 { decay_spectrum(0.10); return; }
|
||||||
|
time_s := app.current_stream.play_cursor / (play_rate * current_rate);
|
||||||
|
ogg_frame := cast(s64)(time_s * ogg_rate);
|
||||||
|
seek_frame := max(0, ogg_frame - FFT_SIZE / 2);
|
||||||
|
|
||||||
|
Stb_Vorbis.stb_vorbis_seek_frame(vorbis, cast(u32) seek_frame);
|
||||||
|
|
||||||
|
nch := cast(s32) min(info.channels, 2);
|
||||||
|
decoded := Stb_Vorbis.stb_vorbis_get_samples_short_interleaved(
|
||||||
|
vorbis, nch, analysis_pcm_buf.data, FFT_SIZE * nch);
|
||||||
|
|
||||||
|
if decoded < FFT_SIZE / 4 {
|
||||||
decay_spectrum(0.10);
|
decay_spectrum(0.10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
nchannels := cast(s64) sd.nchannels;
|
// Mix to mono and apply Hann window.
|
||||||
if nchannels < 1 {
|
inv_chan := 1.0 / cast(float) nch;
|
||||||
decay_spectrum(0.10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
total_frames := sd.nsamples_times_nchannels / nchannels;
|
|
||||||
|
|
||||||
cursor_frame := cast(s64) app.current_stream.play_cursor;
|
|
||||||
start := cursor_frame - FFT_SIZE / 2;
|
|
||||||
if start < 0 start = 0;
|
|
||||||
if start + FFT_SIZE > total_frames start = total_frames - FFT_SIZE;
|
|
||||||
if start < 0 {
|
|
||||||
// track shorter than the FFT window
|
|
||||||
decay_spectrum(0.10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mix down to mono and apply window.
|
|
||||||
samples := sd.samples;
|
|
||||||
inv_chan := 1.0 / cast(float) nchannels;
|
|
||||||
for k: 0..FFT_SIZE-1 {
|
for k: 0..FFT_SIZE-1 {
|
||||||
idx := (start + k) * nchannels;
|
if k >= decoded { fft_re[k] = 0; fft_im[k] = 0; continue; }
|
||||||
sum: float = 0;
|
sum: float = 0;
|
||||||
for ch: 0..nchannels-1 sum += cast(float) samples[idx + ch];
|
for ch: 0..nch-1 sum += cast(float) analysis_pcm_buf[k * nch + ch];
|
||||||
mono := sum * inv_chan / 32768.0; // s16 → [-1, 1]
|
mono := sum * inv_chan / 32768.0;
|
||||||
fft_re[k] = mono * fft_window[k];
|
fft_re[k] = mono * fft_window[k];
|
||||||
fft_im[k] = 0;
|
fft_im[k] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
fft();
|
fft();
|
||||||
|
|
||||||
// Bin into log-spaced bands.
|
rate := ogg_rate;
|
||||||
rate := cast(float) sd.sampling_rate;
|
|
||||||
nyquist := rate * 0.5;
|
nyquist := rate * 0.5;
|
||||||
log_lo := log(MIN_VISUAL_FREQ);
|
log_lo := log(MIN_VISUAL_FREQ);
|
||||||
log_hi := log(nyquist);
|
log_hi := log(nyquist);
|
||||||
@ -85,15 +93,10 @@ update_audio_analysis :: () {
|
|||||||
if mag > peak peak = mag;
|
if mag > peak peak = mag;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize by FFT_SIZE/4 (a Hann-windowed unit sine peaks around
|
|
||||||
// FFT_SIZE/4 in this convention) and compress with sqrt to match
|
|
||||||
// perceptual loudness better.
|
|
||||||
v := peak / (cast(float) FFT_SIZE * 0.25);
|
v := peak / (cast(float) FFT_SIZE * 0.25);
|
||||||
v = sqrt(v);
|
v = sqrt(v);
|
||||||
if v > 1 v = 1;
|
if v > 1 v = 1;
|
||||||
|
|
||||||
// Asymmetric smoothing: fast attack, slow decay (classic
|
|
||||||
// VU-meter feel).
|
|
||||||
prev := app.spectrum[b];
|
prev := app.spectrum[b];
|
||||||
rate_lerp := ifx v > prev then 0.45 else 0.10;
|
rate_lerp := ifx v > prev then 0.45 else 0.10;
|
||||||
app.spectrum[b] = lerp(prev, v, rate_lerp);
|
app.spectrum[b] = lerp(prev, v, rate_lerp);
|
||||||
@ -102,6 +105,8 @@ update_audio_analysis :: () {
|
|||||||
|
|
||||||
#scope_file
|
#scope_file
|
||||||
|
|
||||||
|
analysis_pcm_buf: [FFT_SIZE * 2] s16;
|
||||||
|
|
||||||
decay_spectrum :: (rate: float) {
|
decay_spectrum :: (rate: float) {
|
||||||
for 0..SPECTRUM_BINS-1 {
|
for 0..SPECTRUM_BINS-1 {
|
||||||
app.spectrum[it] = lerp(app.spectrum[it], 0, rate);
|
app.spectrum[it] = lerp(app.spectrum[it], 0, rate);
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
//
|
//
|
||||||
// Audio playback. The track download is async — `audio_play_track` returns
|
// Audio playback. Downloads the track as OGG Vorbis (Jellyfin transcodes
|
||||||
// immediately after queueing the request; when the bytes arrive we decode
|
// everything server-side) and hands it to Sound_Player's native OGG path.
|
||||||
// them and start a Sound_Player stream.
|
|
||||||
//
|
//
|
||||||
// Pause is REAL: setting `current_rate = desired_rate = 0` freezes the
|
// OGG_COMPRESSED streams from a decoder rather than decoding the full PCM
|
||||||
// stream's play_cursor and (with `inaudible = true`) silences output. So
|
// into memory, so there is no large heap buffer to manage — Sound_Player
|
||||||
// while paused the seek bar doesn't drift and auto-advance can't fire.
|
// owns the decoder lifecycle. The only memory we own is the heap-allocated
|
||||||
|
// Sound_Data struct and the raw OGG bytes (stored in sound_data.buffer);
|
||||||
|
// both are freed in sound_release_callback when Sound_Player is done.
|
||||||
//
|
//
|
||||||
|
|
||||||
audio_play_track :: (track: Track) {
|
audio_play_track :: (track: Track) {
|
||||||
@ -14,12 +15,17 @@ audio_play_track :: (track: Track) {
|
|||||||
pending := New(Track);
|
pending := New(Track);
|
||||||
pending.* = clone_track(track);
|
pending.* = clone_track(track);
|
||||||
|
|
||||||
// /universal lets the server pick the best format, transcoding to mp3
|
// Ask Jellyfin to transcode everything to OGG Vorbis 192 kbps.
|
||||||
// when the original isn't in our `container` list. Without this, Opus /
|
// We pin audioSampleRate to Sound_Player's actual output device rate so
|
||||||
// M4A / AAC / WMA / ALAC files come back as bytes we can't decode.
|
// the OGG file's sample rate matches what Sound_Player expects when
|
||||||
|
// calculating desired_rate (= sound_data.sampling_rate / output_rate).
|
||||||
|
// Without this, Jellyfin defaults to 48000 Hz on many systems while
|
||||||
|
// sound_data.sampling_rate stays at 44100 → plays at 91.9% speed.
|
||||||
|
output_hz := Sound.get_audio_sampling_rate();
|
||||||
|
if output_hz <= 0 output_hz = 44100;
|
||||||
path := tprint(
|
path := tprint(
|
||||||
"/Audio/%/universal?container=mp3,flac,ogg,wav&audioCodec=mp3&maxStreamingBitrate=320000&userId=%&deviceId=%&api_key=%",
|
"/Audio/%/universal?container=ogg&audioCodec=vorbis&maxStreamingBitrate=192000&audioSampleRate=%&audioChannels=2&userId=%&deviceId=%&api_key=%",
|
||||||
track.id, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token,
|
track.id, output_hz, app.jellyfin.user_id, DEVICE_ID, app.jellyfin.auth_token,
|
||||||
);
|
);
|
||||||
log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks);
|
log_info("audio: downloading '%' (% ticks)", track.name, track.duration_ticks);
|
||||||
http_submit("GET", path, on_done=on_track_downloaded, user_data=pending);
|
http_submit("GET", path, on_done=on_track_downloaded, user_data=pending);
|
||||||
@ -58,7 +64,23 @@ stop_current_stream :: () {
|
|||||||
app.current_stream = null;
|
app.current_stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
analysis_close :: () {
|
||||||
|
if app.analysis_vorbis {
|
||||||
|
Stb_Vorbis.stb_vorbis_close(cast(*Stb_Vorbis.stb_vorbis) app.analysis_vorbis);
|
||||||
|
app.analysis_vorbis = null;
|
||||||
|
}
|
||||||
|
free(app.analysis_ogg);
|
||||||
|
app.analysis_ogg = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by Sound_Player when it is finished with a stream (natural end or
|
||||||
|
// stop_stream_abruptly). We own the Sound_Data struct and the OGG bytes in
|
||||||
|
// sound_data.buffer; free both here.
|
||||||
sound_release_callback :: (stream: *Sound.Sound_Stream, data: *Sound.Sound_Data) {
|
sound_release_callback :: (stream: *Sound.Sound_Stream, data: *Sound.Sound_Data) {
|
||||||
|
free(data.buffer);
|
||||||
|
free(data.name);
|
||||||
|
free(data);
|
||||||
|
|
||||||
if stream && stream.entity_id == app.track_entity_id {
|
if stream && stream.entity_id == app.track_entity_id {
|
||||||
app.track_finished = true;
|
app.track_finished = true;
|
||||||
app.current_stream = null;
|
app.current_stream = null;
|
||||||
@ -72,8 +94,10 @@ clone_track :: (t: Track) -> Track {
|
|||||||
out.album = copy_string(t.album);
|
out.album = copy_string(t.album);
|
||||||
out.album_id = copy_string(t.album_id);
|
out.album_id = copy_string(t.album_id);
|
||||||
out.artist = copy_string(t.artist);
|
out.artist = copy_string(t.artist);
|
||||||
|
out.artist_id = copy_string(t.artist_id);
|
||||||
out.duration_ticks = t.duration_ticks;
|
out.duration_ticks = t.duration_ticks;
|
||||||
out.index_number = t.index_number;
|
out.index_number = t.index_number;
|
||||||
|
out.is_favourite = t.is_favourite;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +107,7 @@ free_track :: (t: *Track) {
|
|||||||
free(t.album);
|
free(t.album);
|
||||||
free(t.album_id);
|
free(t.album_id);
|
||||||
free(t.artist);
|
free(t.artist);
|
||||||
|
free(t.artist_id);
|
||||||
t.* = .{};
|
t.* = .{};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,25 +122,36 @@ on_track_downloaded :: (task: *Http_Task) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transfer ownership of the bytes to Sound_Data.buffer.
|
||||||
bytes := task.response.body;
|
bytes := task.response.body;
|
||||||
task.response.body = "";
|
task.response.body = "";
|
||||||
|
|
||||||
sd, format, ok := decode_audio(bytes, pending.name);
|
sd := Sound.load_audio_data(pending.name, bytes);
|
||||||
if !ok {
|
if !sd.loaded {
|
||||||
log_error("audio decode failed for '%'", pending.name);
|
log_error("audio: OGG decode failed for '%'", pending.name);
|
||||||
free(bytes);
|
free(bytes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
data := New(Sound.Sound_Data);
|
data := New(Sound.Sound_Data);
|
||||||
data.* = sd;
|
data.* = sd;
|
||||||
|
|
||||||
|
// Open a separate OGG decoder for the visualizer FFT before we transfer
|
||||||
|
// the bytes to Sound_Player (bytes is still valid at this point).
|
||||||
|
analysis_close();
|
||||||
|
app.analysis_ogg = copy_string(bytes); // independent copy — Sound_Player owns `bytes`
|
||||||
|
{
|
||||||
|
err: s32;
|
||||||
|
v := Stb_Vorbis.stb_vorbis_open_memory(
|
||||||
|
app.analysis_ogg.data, cast(s32) app.analysis_ogg.count, *err, null);
|
||||||
|
if v app.analysis_vorbis = v;
|
||||||
|
else log_warn("audio: analysis decoder open failed (err=%)", err);
|
||||||
|
}
|
||||||
|
|
||||||
stop_current_stream();
|
stop_current_stream();
|
||||||
free_track(*app.current_track);
|
free_track(*app.current_track);
|
||||||
app.current_track = clone_track(pending.*);
|
app.current_track = clone_track(pending.*);
|
||||||
app.current_format = format;
|
app.current_format = .OGG;
|
||||||
|
|
||||||
// New track always starts un-paused; user clicking Next/Prev or
|
|
||||||
// auto-advance both want fresh playback state.
|
|
||||||
app.paused = false;
|
app.paused = false;
|
||||||
|
|
||||||
app.track_entity_id += 1;
|
app.track_entity_id += 1;
|
||||||
@ -125,5 +161,6 @@ on_track_downloaded :: (task: *Http_Task) {
|
|||||||
|
|
||||||
app.current_stream = stream;
|
app.current_stream = stream;
|
||||||
app.track_finished = false;
|
app.track_finished = false;
|
||||||
log_info("audio: playing '% — %' [%]", app.current_track.artist, app.current_track.name, format);
|
log_info("audio: playing '% — %' [OGG] artist_id=%",
|
||||||
|
app.current_track.artist, app.current_track.name, app.current_track.artist_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,117 @@
|
|||||||
//
|
//
|
||||||
// Naive play queue. Linear array, current index. Shuffle/repeat punted.
|
// Play queue with optional shuffle.
|
||||||
// audio_play_track is now async (returns void); the queue just fires the
|
|
||||||
// download and trusts it to land.
|
|
||||||
//
|
//
|
||||||
|
// Shuffle mode cycles: OFF → ALL → FAVOURITES → OFF.
|
||||||
|
// In shuffle modes, queue_next/prev walk a Fisher-Yates permutation of track
|
||||||
|
// indices rather than sequential order. FAVOURITES filters the pool to tracks
|
||||||
|
// where is_favourite is true.
|
||||||
|
//
|
||||||
|
|
||||||
|
Shuffle_Mode :: enum {
|
||||||
|
OFF;
|
||||||
|
ALL;
|
||||||
|
FAVOURITES;
|
||||||
|
}
|
||||||
|
|
||||||
Queue :: struct {
|
Queue :: struct {
|
||||||
tracks: [..] Track;
|
tracks: [..] Track;
|
||||||
current: int = -1;
|
current: int = -1;
|
||||||
|
|
||||||
|
shuffle_mode: Shuffle_Mode;
|
||||||
|
shuffle_order: [..] int; // permuted indices into tracks[]
|
||||||
|
shuffle_pos: int = -1; // current position within shuffle_order
|
||||||
}
|
}
|
||||||
|
|
||||||
queue: Queue;
|
queue: Queue;
|
||||||
|
|
||||||
queue_clear :: () {
|
queue_clear :: () {
|
||||||
array_reset(*queue.tracks);
|
array_reset(*queue.tracks);
|
||||||
|
array_reset(*queue.shuffle_order);
|
||||||
queue.current = -1;
|
queue.current = -1;
|
||||||
|
queue.shuffle_pos = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
queue_add :: (track: Track) {
|
queue_add :: (track: Track) {
|
||||||
array_add(*queue.tracks, track);
|
array_add(*queue.tracks, track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a fresh permutation from the current track list. Call after all
|
||||||
|
// queue_adds are done, and whenever shuffle mode changes.
|
||||||
|
queue_rebuild_shuffle :: () {
|
||||||
|
array_reset(*queue.shuffle_order);
|
||||||
|
if queue.shuffle_mode == .OFF return;
|
||||||
|
|
||||||
|
for i: 0..queue.tracks.count-1 {
|
||||||
|
if queue.shuffle_mode == .FAVOURITES && !queue.tracks[i].is_favourite continue;
|
||||||
|
array_add(*queue.shuffle_order, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fisher-Yates — must use while loop; Jai's a..b range goes forward when
|
||||||
|
// a < b, so `for i: n-1..1` would iterate upward and crash on n=1.
|
||||||
|
i := queue.shuffle_order.count - 1;
|
||||||
|
while i > 0 {
|
||||||
|
j := cast(int)(random_get() % cast(u64)(i + 1));
|
||||||
|
tmp := queue.shuffle_order[i];
|
||||||
|
queue.shuffle_order[i] = queue.shuffle_order[j];
|
||||||
|
queue.shuffle_order[j] = tmp;
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the current track at position 0 so << works sensibly immediately.
|
||||||
|
if queue.current >= 0 {
|
||||||
|
for i: 0..queue.shuffle_order.count-1 {
|
||||||
|
if queue.shuffle_order[i] == queue.current {
|
||||||
|
tmp := queue.shuffle_order[0];
|
||||||
|
queue.shuffle_order[0] = queue.shuffle_order[i];
|
||||||
|
queue.shuffle_order[i] = tmp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.shuffle_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue_set_shuffle :: (mode: Shuffle_Mode) {
|
||||||
|
if queue.shuffle_mode == mode return;
|
||||||
|
queue.shuffle_mode = mode;
|
||||||
|
queue_rebuild_shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
queue_play_index :: (index: int) -> bool {
|
queue_play_index :: (index: int) -> bool {
|
||||||
if index < 0 || index >= queue.tracks.count return false;
|
if index < 0 || index >= queue.tracks.count return false;
|
||||||
queue.current = index;
|
queue.current = index;
|
||||||
|
if queue.shuffle_mode != .OFF {
|
||||||
|
for i: 0..queue.shuffle_order.count-1 {
|
||||||
|
if queue.shuffle_order[i] == index {
|
||||||
|
queue.shuffle_pos = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
audio_play_track(queue.tracks[index]);
|
audio_play_track(queue.tracks[index]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
queue_next :: () -> bool {
|
queue_next :: () -> bool {
|
||||||
|
if queue.shuffle_mode != .OFF && queue.shuffle_order.count > 0 {
|
||||||
|
next := queue.shuffle_pos + 1;
|
||||||
|
if next >= queue.shuffle_order.count return false;
|
||||||
|
queue.shuffle_pos = next;
|
||||||
|
queue.current = queue.shuffle_order[next];
|
||||||
|
audio_play_track(queue.tracks[queue.current]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return queue_play_index(queue.current + 1);
|
return queue_play_index(queue.current + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
queue_prev :: () -> bool {
|
queue_prev :: () -> bool {
|
||||||
|
if queue.shuffle_mode != .OFF && queue.shuffle_order.count > 0 {
|
||||||
|
prev := queue.shuffle_pos - 1;
|
||||||
|
if prev < 0 return false;
|
||||||
|
queue.shuffle_pos = prev;
|
||||||
|
queue.current = queue.shuffle_order[prev];
|
||||||
|
audio_play_track(queue.tracks[queue.current]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return queue_play_index(queue.current - 1);
|
return queue_play_index(queue.current - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,16 @@ App :: struct {
|
|||||||
// Per-frame audio analysis used by the visualizer shader.
|
// Per-frame audio analysis used by the visualizer shader.
|
||||||
spectrum: [SPECTRUM_BINS] float;
|
spectrum: [SPECTRUM_BINS] float;
|
||||||
|
|
||||||
|
// Separate OGG decoder kept open for the visualizer FFT. Sound_Player's
|
||||||
|
// OGG_COMPRESSED type gives no PCM access, so we maintain our own handle.
|
||||||
|
analysis_vorbis: *void; // *Stb_Vorbis.stb_vorbis, void to avoid header dep
|
||||||
|
analysis_ogg: string; // copy of OGG bytes kept alive for the decoder
|
||||||
|
|
||||||
|
// Cover art palette — extracted once per album, used for theming.
|
||||||
|
palette: [4] Vector4; // [0]=bg [1]=highlight [2]=mid [3]=accent
|
||||||
|
palette_ready: bool;
|
||||||
|
palette_album_id: string;
|
||||||
|
|
||||||
// Library browse state.
|
// Library browse state.
|
||||||
library: Library_State;
|
library: Library_State;
|
||||||
}
|
}
|
||||||
@ -62,7 +72,7 @@ app_init :: () {
|
|||||||
|
|
||||||
setup_data_directory();
|
setup_data_directory();
|
||||||
|
|
||||||
app.window = create_window(app.window_width, app.window_height, "player");
|
app.window = create_window(app.window_width, app.window_height, "Celica");
|
||||||
Simp.set_render_target(app.window, .LEFT_HANDED);
|
Simp.set_render_target(app.window, .LEFT_HANDED);
|
||||||
|
|
||||||
init_fonts();
|
init_fonts();
|
||||||
|
|||||||
@ -22,3 +22,5 @@ Sound :: #import "Sound_Player";
|
|||||||
Jaison :: #import "Jaison";
|
Jaison :: #import "Jaison";
|
||||||
Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true);
|
Curl :: #import "Curl"()(LINUX_USE_SYSTEM_LIBRARY=true);
|
||||||
Audio_Decoders :: #import "audio_decoders";
|
Audio_Decoders :: #import "audio_decoders";
|
||||||
|
Stb_Image :: #import "stb_image";
|
||||||
|
Stb_Vorbis :: #import "stb_vorbis";
|
||||||
|
|||||||
@ -45,8 +45,7 @@ run_main_loop :: () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw_one_frame :: () {
|
draw_one_frame :: () {
|
||||||
proc := default_theme_procs[app.current_theme];
|
app.theme = player_theme();
|
||||||
app.theme = proc();
|
|
||||||
set_default_theme(app.theme);
|
set_default_theme(app.theme);
|
||||||
|
|
||||||
bg := app.theme.background_color;
|
bg := app.theme.background_color;
|
||||||
@ -55,8 +54,9 @@ draw_one_frame :: () {
|
|||||||
x, y, w, h := get_dimensions(app.window, true);
|
x, y, w, h := get_dimensions(app.window, true);
|
||||||
ui_per_frame_update(app.window, w, h, app.current_time);
|
ui_per_frame_update(app.window, w, h, app.current_time);
|
||||||
|
|
||||||
// The visualizer shader runs *behind* the UI for every view that wants it.
|
// Library: visualizer behind the UI. Now-playing: visualizer is drawn
|
||||||
if app.current_view != .LOGIN {
|
// inside draw_now_playing_view, after the backdrop, so bars sit on top.
|
||||||
|
if app.current_view == .LIBRARY {
|
||||||
gfx_draw_visualizer_background(xx w, xx h);
|
gfx_draw_visualizer_background(xx w, xx h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,7 +82,13 @@ image_request :: (item_id: string, size: Image_Size) -> *Image {
|
|||||||
req.image = img;
|
req.image = img;
|
||||||
req.id = copy_string(item_id);
|
req.id = copy_string(item_id);
|
||||||
req.size = size;
|
req.size = size;
|
||||||
|
// Backdrop and large images are needed immediately for the now-playing
|
||||||
|
// screen; insert at front so they don't queue behind hundreds of thumbs.
|
||||||
|
if size == .BACKDROP || size == .LARGE {
|
||||||
|
array_insert_at(*image_pending, req, 0);
|
||||||
|
} else {
|
||||||
array_add(*image_pending, req);
|
array_add(*image_pending, req);
|
||||||
|
}
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +112,7 @@ image_pump :: () {
|
|||||||
// Do NOT increment i — the array shifted.
|
// Do NOT increment i — the array shifted.
|
||||||
} else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES {
|
} else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES {
|
||||||
url := build_image_url(req.id, req.size);
|
url := build_image_url(req.id, req.size);
|
||||||
|
log_info("image: fetching % (size=%) url=%", req.id, req.size, url);
|
||||||
http_submit("GET", url, on_done=on_image_response, user_data=req);
|
http_submit("GET", url, on_done=on_image_response, user_data=req);
|
||||||
image_in_flight += 1;
|
image_in_flight += 1;
|
||||||
array_ordered_remove_by_index(*image_pending, i);
|
array_ordered_remove_by_index(*image_pending, i);
|
||||||
@ -117,6 +124,39 @@ image_pump :: () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render image filling the rect with cover-fit: maintains aspect ratio,
|
||||||
|
// crops the excess. The center of the image is always centered in the rect.
|
||||||
|
draw_image_cover :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
|
||||||
|
if !img || !img.loaded {
|
||||||
|
Simp.set_shader_for_color();
|
||||||
|
col := ifx img && img.failed then Vector4.{0.18, 0.04, 0.10, 1} else Vector4.{0.10, 0.05, 0.18, 1};
|
||||||
|
Simp.immediate_quad(x, y, x + w, y + h, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
iw := cast(float) img.texture.width;
|
||||||
|
ih := cast(float) img.texture.height;
|
||||||
|
if iw < 1 || ih < 1 { draw_image(x, y, w, h, img, tint); return; }
|
||||||
|
|
||||||
|
scale := max(w / iw, h / ih);
|
||||||
|
u_range := w / (iw * scale);
|
||||||
|
v_range := h / (ih * scale);
|
||||||
|
u0 := (1.0 - u_range) * 0.5;
|
||||||
|
v0 := (1.0 - v_range) * 0.5;
|
||||||
|
u1 := u0 + u_range;
|
||||||
|
v1 := v0 + v_range;
|
||||||
|
|
||||||
|
Simp.set_shader_for_images(*img.texture);
|
||||||
|
Simp.immediate_quad(
|
||||||
|
Vector2.{x, y },
|
||||||
|
Vector2.{x + w, y },
|
||||||
|
Vector2.{x + w, y + h},
|
||||||
|
Vector2.{x, y + h},
|
||||||
|
color = tint,
|
||||||
|
uv0 = .{u0, v0}, uv1 = .{u1, v0},
|
||||||
|
uv2 = .{u1, v1}, uv3 = .{u0, v1},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
|
draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
|
||||||
if img && img.loaded {
|
if img && img.loaded {
|
||||||
Simp.set_shader_for_images(*img.texture);
|
Simp.set_shader_for_images(*img.texture);
|
||||||
@ -192,20 +232,30 @@ consume_disk_hit :: (req: *Image_Request, disk_path: string) {
|
|||||||
img := req.image;
|
img := req.image;
|
||||||
img.loading = false;
|
img.loading = false;
|
||||||
|
|
||||||
|
log_info("image: disk hit id=% size=%", req.id, req.size);
|
||||||
|
|
||||||
bytes, ok := read_entire_file(disk_path, log_errors=false);
|
bytes, ok := read_entire_file(disk_path, log_errors=false);
|
||||||
if !ok {
|
if !ok {
|
||||||
|
log_warn("image: disk read failed id=% size=%", req.id, req.size);
|
||||||
img.failed = true;
|
img.failed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defer free(bytes);
|
defer free(bytes);
|
||||||
|
|
||||||
|
if req.size == .LARGE palette_extract(req.id, bytes);
|
||||||
|
|
||||||
buf: [] u8 = ---;
|
buf: [] u8 = ---;
|
||||||
buf.data = bytes.data;
|
buf.data = bytes.data;
|
||||||
buf.count = bytes.count;
|
buf.count = bytes.count;
|
||||||
if !Simp.texture_load_from_memory(*img.texture, buf) {
|
if !Simp.texture_load_from_memory(*img.texture, buf) {
|
||||||
|
// Cached file is corrupt (e.g. a Jellyfin error response saved as
|
||||||
|
// bytes). Delete it so the next launch re-fetches.
|
||||||
|
log_warn("image: corrupt disk cache id=% size=%, deleting %", req.id, req.size, disk_path);
|
||||||
|
file_delete(disk_path);
|
||||||
img.failed = true;
|
img.failed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log_info("image: disk loaded id=% size=% %x%", req.id, req.size, img.texture.width, img.texture.height);
|
||||||
img.loaded = true;
|
img.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +269,8 @@ on_image_response :: (task: *Http_Task) {
|
|||||||
img.loading = false;
|
img.loading = false;
|
||||||
|
|
||||||
if !task.response.ok || task.response.body.count == 0 {
|
if !task.response.ok || task.response.body.count == 0 {
|
||||||
|
log_warn("image: FAILED id=% size=% status=% body_len=%",
|
||||||
|
req.id, req.size, task.response.status_code, task.response.body.count);
|
||||||
img.failed = true;
|
img.failed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -229,12 +281,17 @@ on_image_response :: (task: *Http_Task) {
|
|||||||
disk_path := image_disk_path(req.id, req.size);
|
disk_path := image_disk_path(req.id, req.size);
|
||||||
write_entire_file(disk_path, task.response.body);
|
write_entire_file(disk_path, task.response.body);
|
||||||
|
|
||||||
|
if req.size == .LARGE palette_extract(req.id, task.response.body);
|
||||||
|
|
||||||
body_bytes: [] u8 = ---;
|
body_bytes: [] u8 = ---;
|
||||||
body_bytes.data = task.response.body.data;
|
body_bytes.data = task.response.body.data;
|
||||||
body_bytes.count = task.response.body.count;
|
body_bytes.count = task.response.body.count;
|
||||||
if !Simp.texture_load_from_memory(*img.texture, body_bytes) {
|
if !Simp.texture_load_from_memory(*img.texture, body_bytes) {
|
||||||
|
log_warn("image: texture decode failed id=% size=% body_len=%", req.id, req.size, task.response.body.count);
|
||||||
img.failed = true;
|
img.failed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log_info("image: loaded id=% size=% %x% body_len=%",
|
||||||
|
req.id, req.size, img.texture.width, img.texture.height, task.response.body.count);
|
||||||
img.loaded = true;
|
img.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
#load "shaders.jai";
|
#load "shaders.jai";
|
||||||
#load "images.jai";
|
#load "images.jai";
|
||||||
|
#load "palette.jai";
|
||||||
|
|||||||
165
src/gfx/palette.jai
Normal file
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.
|
// Nothing yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
gfx_draw_visualizer_background :: (w: float, h: float) {
|
gfx_draw_visualizer_background :: (w: float, h: float, bg_alpha := 1.0) {
|
||||||
update_audio_analysis();
|
update_audio_analysis();
|
||||||
Simp.set_shader_for_color();
|
|
||||||
|
|
||||||
t := cast(float) app.current_time;
|
t := cast(float) app.current_time;
|
||||||
|
|
||||||
// Bass = average of the lowest ~16% of bins. Drives a subtle base
|
|
||||||
// brightness pulse so the whole screen breathes with the kick drum.
|
|
||||||
bass: float = 0;
|
bass: float = 0;
|
||||||
bass_bins := SPECTRUM_BINS / 6;
|
bass_bins := SPECTRUM_BINS / 6;
|
||||||
if bass_bins < 1 bass_bins = 1;
|
if bass_bins < 1 bass_bins = 1;
|
||||||
for 0..bass_bins-1 bass += app.spectrum[it];
|
for 0..bass_bins-1 bass += app.spectrum[it];
|
||||||
bass /= cast(float) bass_bins;
|
bass /= cast(float) bass_bins;
|
||||||
|
|
||||||
base := Vector4.{
|
{
|
||||||
0.04 + 0.10 * bass,
|
base: Vector4;
|
||||||
0.02 + 0.04 * bass,
|
if app.palette_ready {
|
||||||
0.10 + 0.18 * bass,
|
p := app.palette[0];
|
||||||
1,
|
base = .{p.x + 0.08 * bass, p.y + 0.04 * bass, p.z + 0.12 * bass, bg_alpha};
|
||||||
};
|
} else {
|
||||||
|
base = .{0.04 + 0.10 * bass, 0.02 + 0.04 * bass, 0.10 + 0.18 * bass, bg_alpha};
|
||||||
|
}
|
||||||
|
Simp.set_shader_for_color(enable_blend = bg_alpha < 1.0);
|
||||||
Simp.immediate_quad(0, 0, w, h, base);
|
Simp.immediate_quad(0, 0, w, h, base);
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrored bars from the horizontal mid-line — bass on the left, treble
|
// Mirrored bars from the horizontal mid-line — bass on the left, treble
|
||||||
// on the right, both spreading top and bottom.
|
// on the right, both spreading top and bottom.
|
||||||
@ -47,11 +48,37 @@ gfx_draw_visualizer_background :: (w: float, h: float) {
|
|||||||
x1 := x0 + bar_w * 0.85;
|
x1 := x0 + bar_w * 0.85;
|
||||||
|
|
||||||
hue := cast(float) i / cast(float) SPECTRUM_BINS;
|
hue := cast(float) i / cast(float) SPECTRUM_BINS;
|
||||||
body := neon_color(hue, v, t);
|
body: Vector4;
|
||||||
|
if app.palette_ready {
|
||||||
|
// 4-stop gradient across the spectrum using all palette colours.
|
||||||
|
// hue goes 0..1 left-to-right; map onto 3 segments:
|
||||||
|
// [0, 0.33] palette[1] → palette[2]
|
||||||
|
// [0.33,0.66] palette[2] → palette[3]
|
||||||
|
// [0.66, 1] palette[3] → palette[1] (wraps back for continuity)
|
||||||
|
c: Vector4;
|
||||||
|
if hue < 0.333 {
|
||||||
|
t2 := hue / 0.333;
|
||||||
|
c1 := app.palette[1]; c2 := app.palette[2];
|
||||||
|
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
|
||||||
|
} else if hue < 0.666 {
|
||||||
|
t2 := (hue - 0.333) / 0.333;
|
||||||
|
c1 := app.palette[2]; c2 := app.palette[3];
|
||||||
|
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
|
||||||
|
} else {
|
||||||
|
t2 := (hue - 0.666) / 0.334;
|
||||||
|
c1 := app.palette[3]; c2 := app.palette[1];
|
||||||
|
c = .{c1.x+(c2.x-c1.x)*t2, c1.y+(c2.y-c1.y)*t2, c1.z+(c2.z-c1.z)*t2, 1};
|
||||||
|
}
|
||||||
|
// Modulate brightness by amplitude; dim quiet bars a little.
|
||||||
|
bright := 0.45 + 0.55 * v;
|
||||||
|
body = .{c.x * bright, c.y * bright, c.z * bright, 0.65 + 0.35 * v};
|
||||||
|
} else {
|
||||||
|
body = neon_color(hue, v, t);
|
||||||
|
}
|
||||||
tip := body;
|
tip := body;
|
||||||
tip.x = min(1.0, tip.x + 0.4);
|
tip.x = min(1.0, tip.x + 0.35);
|
||||||
tip.y = min(1.0, tip.y + 0.4);
|
tip.y = min(1.0, tip.y + 0.35);
|
||||||
tip.z = min(1.0, tip.z + 0.4);
|
tip.z = min(1.0, tip.z + 0.35);
|
||||||
|
|
||||||
// Top half (going up).
|
// Top half (going up).
|
||||||
Simp.immediate_quad(x0, cy - bar_h, x1, cy, body);
|
Simp.immediate_quad(x0, cy - bar_h, x1, cy, body);
|
||||||
@ -66,11 +93,11 @@ gfx_draw_visualizer_background :: (w: float, h: float) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center mirror line — a thin neon glow across the middle for the
|
// Center mirror line — a thin neon glow across the middle.
|
||||||
// 2000s-skin vibe.
|
|
||||||
{
|
{
|
||||||
line_h := 1.5;
|
line_h := 1.5;
|
||||||
line_col := Vector4.{1.0, 0.4, 0.8, 0.5 + 0.3 * bass};
|
line_col := ifx app.palette_ready then app.palette[1] else Vector4.{1.0, 0.4, 0.8, 1};
|
||||||
|
line_col.w = 0.5 + 0.3 * bass;
|
||||||
Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col);
|
Simp.immediate_quad(0, cy - line_h * 0.5, w, cy + line_h * 0.5, line_col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 "library.jai";
|
||||||
#load "images.jai";
|
#load "images.jai";
|
||||||
#load "stream.jai";
|
#load "stream.jai";
|
||||||
|
#load "favourites.jai";
|
||||||
|
#load "shuffle.jai";
|
||||||
|
|||||||
@ -30,6 +30,7 @@ Track :: struct {
|
|||||||
artist_id: string;
|
artist_id: string;
|
||||||
duration_ticks: s64;
|
duration_ticks: s64;
|
||||||
index_number: int;
|
index_number: int;
|
||||||
|
is_favourite: bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
Library_State :: struct {
|
Library_State :: struct {
|
||||||
@ -60,13 +61,26 @@ Library_State :: struct {
|
|||||||
|
|
||||||
#scope_module
|
#scope_module
|
||||||
|
|
||||||
|
// Jellyfin returns the artist ID inside AlbumArtists[].Id, not as a flat
|
||||||
|
// AlbumArtistId field on track items.
|
||||||
|
Artist_Ref :: struct {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
User_Data_Dto :: struct {
|
||||||
|
IsFavorite: bool;
|
||||||
|
}
|
||||||
|
|
||||||
Item_Summary :: struct {
|
Item_Summary :: struct {
|
||||||
Id: string;
|
Id: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Album: string;
|
Album: string;
|
||||||
AlbumId: string;
|
AlbumId: string;
|
||||||
AlbumArtist: string;
|
AlbumArtist: string;
|
||||||
AlbumArtistId: string;
|
AlbumArtistId: string; // populated for album items
|
||||||
|
AlbumArtists: [..] Artist_Ref; // populated for track items
|
||||||
|
UserData: User_Data_Dto;
|
||||||
RunTimeTicks: s64;
|
RunTimeTicks: s64;
|
||||||
IndexNumber: int;
|
IndexNumber: int;
|
||||||
}
|
}
|
||||||
@ -215,9 +229,12 @@ on_tracks_loaded :: (task: *Http_Task) {
|
|||||||
t.album = copy_string(it.Album);
|
t.album = copy_string(it.Album);
|
||||||
t.album_id = copy_string(it.AlbumId);
|
t.album_id = copy_string(it.AlbumId);
|
||||||
t.artist = copy_string(it.AlbumArtist);
|
t.artist = copy_string(it.AlbumArtist);
|
||||||
t.artist_id = copy_string(it.AlbumArtistId);
|
t.artist_id = ifx it.AlbumArtists.count > 0
|
||||||
|
then copy_string(it.AlbumArtists[0].Id)
|
||||||
|
else copy_string(it.AlbumArtistId); // fallback
|
||||||
t.duration_ticks = it.RunTimeTicks;
|
t.duration_ticks = it.RunTimeTicks;
|
||||||
t.index_number = it.IndexNumber;
|
t.index_number = it.IndexNumber;
|
||||||
|
t.is_favourite = it.UserData.IsFavorite;
|
||||||
array_add(*app.library.tracks, t);
|
array_add(*app.library.tracks, t);
|
||||||
}
|
}
|
||||||
app.library.tracks_loaded = true;
|
app.library.tracks_loaded = true;
|
||||||
|
|||||||
83
src/jellyfin/shuffle.jai
Normal file
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_DIR :: "data/fonts";
|
||||||
FONT_FILE :: "OpenSans-BoldItalic.ttf";
|
FONT_FILE :: "bitfont.ttf";
|
||||||
|
|
||||||
init_fonts :: () {
|
init_fonts :: () {
|
||||||
h := app.window_height;
|
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"
|
// Custom player theme. Rebuilt every frame so it adapts to the album-art
|
||||||
// vibe but you can pick any of the bundled themes via app.current_theme.
|
// palette automatically. Near-zero corner rounding throughout.
|
||||||
//
|
|
||||||
// Eventually we'll write a fully custom Overall_Theme here with chunky bevels,
|
|
||||||
// big rounding, and neon accents. For MVP we lean on the existing theme procs
|
|
||||||
// and let the visualizer shader carry the aesthetic.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
ui_theme_init :: () {
|
ui_theme_init :: () {}
|
||||||
// Default_Themes.Blood_Vampire is the loudest stock theme — fits the brief.
|
|
||||||
app.current_theme = xx Default_Themes.Blood_Vampire;
|
player_theme :: () -> Overall_Theme {
|
||||||
|
// Pull colours from the current palette, fall back to deep violet.
|
||||||
|
surface: Vector4;
|
||||||
|
border: Vector4;
|
||||||
|
interact: Vector4;
|
||||||
|
|
||||||
|
if app.palette_ready {
|
||||||
|
// bg → widget surface (slightly lightened so it reads as a surface)
|
||||||
|
p0 := app.palette[0];
|
||||||
|
surface = .{p0.x * 1.6 + 0.04, p0.y * 1.6 + 0.02, p0.z * 1.6 + 0.06, 1};
|
||||||
|
// highlight → interact colour
|
||||||
|
p1 := app.palette[1];
|
||||||
|
interact = .{p1.x * 0.75, p1.y * 0.75, p1.z * 0.75, 1};
|
||||||
|
// mid → border
|
||||||
|
p2 := app.palette[2];
|
||||||
|
border = .{p2.x * 0.45, p2.y * 0.45, p2.z * 0.45, 1};
|
||||||
|
} else {
|
||||||
|
surface = .{0.09, 0.05, 0.15, 1};
|
||||||
|
border = .{0.24, 0.09, 0.42, 1};
|
||||||
|
interact = .{0.52, 0.13, 0.78, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
text_lo :: Vector4.{0.75, 0.72, 0.90, 1}; // used internally by set_theme_from_base_colors
|
||||||
|
text_hi :: Vector4.{1.00, 1.00, 1.00, 1};
|
||||||
|
|
||||||
|
ROUNDING :: 0.0; // Fully squared off — hardware aesthetic.
|
||||||
|
|
||||||
|
result: Overall_Theme;
|
||||||
|
set_theme_from_base_colors(*result, surface, border, interact, text_lo, text_hi);
|
||||||
|
|
||||||
|
// Override the bluish text that set_theme_from_base_colors produces.
|
||||||
|
// Match the album-label style from now_playing_view: palette[3] lightened,
|
||||||
|
// or a pure neutral off-white when no palette is loaded.
|
||||||
|
body_text: Vector4;
|
||||||
|
if app.palette_ready {
|
||||||
|
p := app.palette[3];
|
||||||
|
body_text = .{p.x * 0.55 + 0.40, p.y * 0.55 + 0.40, p.z * 0.55 + 0.40, 1};
|
||||||
|
} else {
|
||||||
|
body_text = .{0.94, 0.94, 0.94, 1}; // neutral — no blue tint
|
||||||
|
}
|
||||||
|
WHITE :: Vector4.{1, 1, 1, 1};
|
||||||
|
result.label_theme.text_color = body_text;
|
||||||
|
result.text_color = body_text;
|
||||||
|
result.button_theme.text_color = body_text;
|
||||||
|
result.button_theme.text_color_over = WHITE;
|
||||||
|
|
||||||
|
result.background_color = ifx app.palette_ready
|
||||||
|
then Vector4.{app.palette[0].x * 0.9, app.palette[0].y * 0.9, app.palette[0].z * 0.9, 1}
|
||||||
|
else Vector4.{0.04, 0.02, 0.08, 1};
|
||||||
|
result.background_color_bright = surface;
|
||||||
|
|
||||||
|
result.button_theme.rectangle_shape.roundedness = ROUNDING;
|
||||||
|
|
||||||
|
{
|
||||||
|
using result.scrollable_region_theme;
|
||||||
|
region_background.shape.roundedness = ROUNDING;
|
||||||
|
scrollbar_background.shape.roundedness = ROUNDING;
|
||||||
|
scrollbar_background.color = result.background_color;
|
||||||
|
scrollbar_background.frame_color = border;
|
||||||
|
scrollbar_nib_theme.surface_color = border;
|
||||||
|
scrollbar_nib_theme.surface_color_over = interact;
|
||||||
|
scrollbar_nib_theme.surface_color_down = interact;
|
||||||
|
scrollbar_nib_theme.frame_color = border;
|
||||||
|
scrollbar_nib_theme.frame_color_over = interact;
|
||||||
|
scrollbar_nib_theme.rectangle_shape.roundedness = ROUNDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
using result.slider_theme;
|
||||||
|
foreground.surface_color = interact;
|
||||||
|
foreground.surface_color_over = .{min(interact.x+0.12,1), min(interact.y+0.12,1), min(interact.z+0.12,1), 1};
|
||||||
|
foreground.surface_color_flash = foreground.surface_color_over;
|
||||||
|
foreground.surface_color_down = interact;
|
||||||
|
foreground.frame_color = .{interact.x*0.55, interact.y*0.55, interact.z*0.55, 1};
|
||||||
|
foreground.frame_color_over = interact;
|
||||||
|
foreground.text_color = text_hi;
|
||||||
|
foreground.rectangle_shape.roundedness = ROUNDING;
|
||||||
|
|
||||||
|
background.surface_color = result.background_color;
|
||||||
|
background.surface_color_over = result.background_color;
|
||||||
|
background.frame_color = border;
|
||||||
|
background.frame_color_over = border;
|
||||||
|
background.rectangle_shape.roundedness = ROUNDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
using result.text_input_theme;
|
||||||
|
surface_color = .{surface.x * 0.7, surface.y * 0.7, surface.z * 0.7, 1};
|
||||||
|
surface_color_over = surface;
|
||||||
|
frame_color = border;
|
||||||
|
frame_color_over = interact;
|
||||||
|
rectangle_shape.roundedness = ROUNDING;
|
||||||
|
text_color = text_hi;
|
||||||
|
text_color_over = text_hi;
|
||||||
|
selection_color = interact;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
draw_library_view :: () {
|
draw_library_view :: () {
|
||||||
|
artist_alpha_jump();
|
||||||
|
|
||||||
w := cast(float) app.window_width;
|
w := cast(float) app.window_width;
|
||||||
h := cast(float) app.window_height;
|
h := cast(float) app.window_height;
|
||||||
|
|
||||||
@ -26,21 +28,38 @@ draw_library_header :: (x: float, y: float, w: float, h: float) {
|
|||||||
label_theme := app.theme.label_theme;
|
label_theme := app.theme.label_theme;
|
||||||
label_theme.font = app.title_font;
|
label_theme.font = app.title_font;
|
||||||
label_theme.alignment = .Left;
|
label_theme.alignment = .Left;
|
||||||
label_theme.text_color = .{1, 0.4, 0.8, 1};
|
label_theme.text_color = ifx app.palette_ready then app.palette[1] else Vector4.{1, 0.4, 0.8, 1};
|
||||||
label(get_rect(x + h * 0.4, y + h * 0.1, w * 0.5, h * 0.8), "PLAYER", *label_theme);
|
label(get_rect(x + h * 0.4, y + h * 0.1, w * 0.5, h * 0.8), "CELICA", *label_theme);
|
||||||
|
|
||||||
button_theme := app.theme.button_theme;
|
button_theme := app.theme.button_theme;
|
||||||
button_theme.font = app.button_font;
|
button_theme.font = app.button_font;
|
||||||
button_theme.label_theme.alignment = .Center;
|
button_theme.label_theme.alignment = .Center;
|
||||||
|
|
||||||
bw := w * 0.10;
|
|
||||||
bh := h * 0.5;
|
bh := h * 0.5;
|
||||||
if button(get_rect(x + w - bw - h * 0.4, y + h * 0.25, bw, bh), "logout", *button_theme) {
|
by := y + h * 0.25;
|
||||||
|
bw_sm := w * 0.08;
|
||||||
|
bw_lg := w * 0.12;
|
||||||
|
|
||||||
|
// Logout — far right
|
||||||
|
if button(get_rect(x + w - bw_sm - h * 0.4, by, bw_sm, bh), "logout", *button_theme, identifier=10) {
|
||||||
jellyfin_logout(*app.jellyfin);
|
jellyfin_logout(*app.jellyfin);
|
||||||
free(app.jellyfin.password);
|
free(app.jellyfin.password);
|
||||||
app.jellyfin.password = copy_string("");
|
app.jellyfin.password = copy_string("");
|
||||||
app.current_view = .LOGIN;
|
app.current_view = .LOGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shuffle start buttons — queue current tracks and begin playing shuffled.
|
||||||
|
gap := bw_lg * 0.15;
|
||||||
|
sfav_x := x + w - bw_sm - bw_lg - h * 0.4 - gap;
|
||||||
|
sf_x := sfav_x - bw_lg - gap;
|
||||||
|
|
||||||
|
if button(get_rect(sfav_x, by, bw_lg, bh), "Shuffle Fav", *button_theme, identifier=12) {
|
||||||
|
library_start_global_shuffle(.FAVOURITES);
|
||||||
|
}
|
||||||
|
|
||||||
|
if button(get_rect(sf_x, by, bw_lg, bh), "Shuffle", *button_theme, identifier=11) {
|
||||||
|
library_start_global_shuffle(.ALL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_library_columns :: (x: float, y: float, w: float, h: float) {
|
draw_library_columns :: (x: float, y: float, w: float, h: float) {
|
||||||
@ -77,8 +96,8 @@ draw_artists_column :: (x: float, y: float, w: float, h: float) {
|
|||||||
active := app.library.selected_artist_id == it.id;
|
active := app.library.selected_artist_id == it.id;
|
||||||
bt := button_theme;
|
bt := button_theme;
|
||||||
if active {
|
if active {
|
||||||
bt.surface_color = .{0.35, 0.10, 0.45, 1};
|
bt.surface_color = lib_sel_color();
|
||||||
bt.text_color = .{1, 1, 1, 1};
|
bt.text_color = lib_sel_text();
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb_size := row_h;
|
thumb_size := row_h;
|
||||||
@ -119,8 +138,8 @@ draw_albums_column :: (x: float, y: float, w: float, h: float) {
|
|||||||
active := app.library.selected_album_id == it.id;
|
active := app.library.selected_album_id == it.id;
|
||||||
bt := button_theme;
|
bt := button_theme;
|
||||||
if active {
|
if active {
|
||||||
bt.surface_color = .{0.35, 0.10, 0.45, 1};
|
bt.surface_color = lib_sel_color();
|
||||||
bt.text_color = .{1, 1, 1, 1};
|
bt.text_color = lib_sel_text();
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb_size := row_h;
|
thumb_size := row_h;
|
||||||
@ -161,8 +180,8 @@ draw_tracks_column :: (x: float, y: float, w: float, h: float) {
|
|||||||
playing := app.current_stream && app.current_track.id == it.id;
|
playing := app.current_stream && app.current_track.id == it.id;
|
||||||
bt := button_theme;
|
bt := button_theme;
|
||||||
if playing {
|
if playing {
|
||||||
bt.surface_color = .{0.10, 0.40, 0.55, 1};
|
bt.surface_color = lib_play_color();
|
||||||
bt.text_color = .{1, 1, 1, 1};
|
bt.text_color = lib_sel_text();
|
||||||
}
|
}
|
||||||
|
|
||||||
label_text := tprint("%. %", it.index_number, it.name);
|
label_text := tprint("%. %", it.index_number, it.name);
|
||||||
@ -172,6 +191,7 @@ draw_tracks_column :: (x: float, y: float, w: float, h: float) {
|
|||||||
queue_add(tr);
|
queue_add(tr);
|
||||||
if tr.id == it.id queue.current = idx;
|
if tr.id == it.id queue.current = idx;
|
||||||
}
|
}
|
||||||
|
if queue.shuffle_mode != .OFF queue_rebuild_shuffle();
|
||||||
audio_play_track(it);
|
audio_play_track(it);
|
||||||
app.current_view = .NOW_PLAYING;
|
app.current_view = .NOW_PLAYING;
|
||||||
}
|
}
|
||||||
@ -185,7 +205,7 @@ draw_column_header :: (x: float, y: float, w: float, h: float, text: string) {
|
|||||||
label_theme := app.theme.label_theme;
|
label_theme := app.theme.label_theme;
|
||||||
label_theme.font = app.button_font;
|
label_theme.font = app.button_font;
|
||||||
label_theme.alignment = .Left;
|
label_theme.alignment = .Left;
|
||||||
label_theme.text_color = .{0.6, 0.9, 1.0, 1};
|
label_theme.text_color = ifx app.palette_ready then app.palette[2] else Vector4.{0.6, 0.9, 1.0, 1};
|
||||||
label(get_rect(x, y, w, h), text, *label_theme);
|
label(get_rect(x, y, w, h), text, *label_theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,10 +251,17 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) {
|
|||||||
bw := h * 1.3;
|
bw := h * 1.3;
|
||||||
bh := h * 0.55;
|
bh := h * 0.55;
|
||||||
spacing := h * 0.15;
|
spacing := h * 0.15;
|
||||||
cluster_w := bw * 4 + spacing * 3;
|
cluster_w := bw * 5 + spacing * 4;
|
||||||
bx := x + w - cluster_w - h * 0.3;
|
bx := x + w - cluster_w - h * 0.3;
|
||||||
by := y + h * 0.225;
|
by := y + h * 0.225;
|
||||||
|
|
||||||
|
shuf_label := ifx queue.shuffle_mode == .OFF then "seq" else ifx queue.shuffle_mode == .ALL then "shuf" else "shuf*";
|
||||||
|
if button(get_rect(bx, by, bw, bh), shuf_label, *button_theme) {
|
||||||
|
if queue.shuffle_mode == .OFF queue_set_shuffle(.ALL);
|
||||||
|
else if queue.shuffle_mode == .ALL queue_set_shuffle(.FAVOURITES);
|
||||||
|
else queue_set_shuffle(.OFF);
|
||||||
|
}
|
||||||
|
bx += bw + spacing;
|
||||||
if button(get_rect(bx, by, bw, bh), "<<", *button_theme) queue_prev();
|
if button(get_rect(bx, by, bw, bh), "<<", *button_theme) queue_prev();
|
||||||
bx += bw + spacing;
|
bx += bw + spacing;
|
||||||
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
|
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
|
||||||
@ -244,3 +271,70 @@ draw_transport_strip :: (x: float, y: float, w: float, h: float) {
|
|||||||
bx += bw + spacing;
|
bx += bw + spacing;
|
||||||
if button(get_rect(bx, by, bw, bh), "now", *button_theme) app.current_view = .NOW_PLAYING;
|
if button(get_rect(bx, by, bw, bh), "now", *button_theme) app.current_view = .NOW_PLAYING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
// Scroll the artist column to the first artist whose name starts with the
|
||||||
|
// pressed letter. Works off TEXT_INPUT events so it respects the OS key map.
|
||||||
|
artist_alpha_jump :: () {
|
||||||
|
if !app.library.artists_loaded return;
|
||||||
|
|
||||||
|
for Input.events_this_frame {
|
||||||
|
if it.type != .TEXT_INPUT continue;
|
||||||
|
|
||||||
|
ch := it.utf32;
|
||||||
|
if ch >= 97 && ch <= 122 ch -= 32; // normalise to upper-case
|
||||||
|
if ch < 65 || ch > 90 continue; // ignore non-letters
|
||||||
|
|
||||||
|
row_h := app.row_font.character_height * 1.8;
|
||||||
|
for artist, idx: app.library.artists {
|
||||||
|
if artist.name.count == 0 continue;
|
||||||
|
name := artist_sort_name(artist.name);
|
||||||
|
first := cast(u32) name[0];
|
||||||
|
if first >= 97 && first <= 122 first -= 32;
|
||||||
|
if first == ch {
|
||||||
|
app.library.artists_scroll = cast(float) idx * row_h * 1.05;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip leading articles so "The Beatles" sorts/jumps as "Beatles".
|
||||||
|
artist_sort_name :: (name: string) -> string {
|
||||||
|
if begins_with_nocase(name, "the ") && name.count > 4 return slice(name, 4, name.count - 4);
|
||||||
|
if begins_with_nocase(name, "a ") && name.count > 2 return slice(name, 2, name.count - 2);
|
||||||
|
if begins_with_nocase(name, "an ") && name.count > 3 return slice(name, 3, name.count - 3);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
begins_with_nocase :: (s: string, prefix: string) -> bool {
|
||||||
|
if s.count < prefix.count return false;
|
||||||
|
for i: 0..prefix.count-1 {
|
||||||
|
sc := s[i]; pc := prefix[i];
|
||||||
|
if sc >= #char "A" && sc <= #char "Z" sc += 32;
|
||||||
|
if sc != pc return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection highlight — palette[1] darkened to read as a background fill.
|
||||||
|
lib_sel_color :: () -> Vector4 {
|
||||||
|
if !app.palette_ready return .{0.35, 0.10, 0.45, 1};
|
||||||
|
p := app.palette[1];
|
||||||
|
return .{p.x * 0.40, p.y * 0.40, p.z * 0.40, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently-playing highlight — palette[2] darkened, distinct from selection.
|
||||||
|
lib_play_color :: () -> Vector4 {
|
||||||
|
if !app.palette_ready return .{0.10, 0.40, 0.55, 1};
|
||||||
|
p := app.palette[2];
|
||||||
|
return .{p.x * 0.38, p.y * 0.38, p.z * 0.38, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text on a highlighted row — bright version of palette[1].
|
||||||
|
lib_sel_text :: () -> Vector4 {
|
||||||
|
if !app.palette_ready return .{1, 1, 1, 1};
|
||||||
|
p := app.palette[1];
|
||||||
|
return .{min(p.x * 1.4 + 0.2, 1), min(p.y * 1.4 + 0.2, 1), min(p.z * 1.4 + 0.2, 1), 1};
|
||||||
|
}
|
||||||
|
|||||||
@ -1,186 +1,328 @@
|
|||||||
//
|
//
|
||||||
// Now playing — the marquee screen. Visualizer shader runs behind this in
|
// Now playing — adapts to three layout modes based on the window aspect ratio:
|
||||||
// window.jai, so the foreground here is just track info, transport, and
|
// TALL (aspect < 0.90) — portrait: art on top, info stacked below
|
||||||
// the seek/volume sliders.
|
// NORMAL (0.90 – 1.90) — landscape: art left, info right
|
||||||
//
|
// WIDE (aspect > 1.90) — ultra-wide: more padding, larger art
|
||||||
// Artist backdrop (if available) renders full-screen behind everything with
|
|
||||||
// a dark tint overlay so the UI remains readable.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
draw_now_playing_view :: () {
|
draw_now_playing_view :: () {
|
||||||
w := cast(float) app.window_width;
|
w := cast(float) app.window_width;
|
||||||
h := cast(float) app.window_height;
|
h := cast(float) app.window_height;
|
||||||
k := h * 0.05;
|
|
||||||
|
|
||||||
has_track := app.current_stream != null || app.current_track.id.count > 0;
|
has_track := app.current_stream != null || app.current_track.id.count > 0;
|
||||||
|
|
||||||
//
|
draw_now_playing_backdrop(w, h, has_track);
|
||||||
// Full-screen artist backdrop (drawn first, behind everything).
|
|
||||||
// We tint it dark so the UI text remains readable.
|
aspect := w / h;
|
||||||
//
|
if aspect < 0.90 {
|
||||||
if has_track && app.current_track.artist_id.count > 0 {
|
draw_now_playing_tall(w, h, has_track);
|
||||||
backdrop := image_request(app.current_track.artist_id, .BACKDROP);
|
} else if aspect > 1.90 {
|
||||||
// Draw backdrop full-screen
|
draw_now_playing_wide(w, h, has_track);
|
||||||
draw_image(0, 0, w, h, backdrop);
|
|
||||||
// Dark overlay: 60% black so the UI pops against busy backdrops
|
|
||||||
Simp.set_shader_for_color();
|
|
||||||
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 0.60});
|
|
||||||
} else {
|
} else {
|
||||||
// No track playing — solid background
|
draw_now_playing_normal(w, h, has_track);
|
||||||
Simp.set_shader_for_color();
|
|
||||||
Simp.immediate_quad(0, 0, w, h, .{0.05, 0.02, 0.08, 1});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top-left: back to library.
|
|
||||||
{
|
|
||||||
button_theme := app.theme.button_theme;
|
|
||||||
button_theme.font = app.button_font;
|
|
||||||
button_theme.label_theme.alignment = .Center;
|
|
||||||
if button(get_rect(k * 0.6, k * 0.6, w * 0.16, k), "library", *button_theme) {
|
|
||||||
app.current_view = .LIBRARY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout: art on the left, info column on the right.
|
|
||||||
art_size := min(w * 0.32, h * 0.55);
|
|
||||||
art_x := w * 0.06;
|
|
||||||
art_y := h * 0.12;
|
|
||||||
|
|
||||||
info_x := art_x + art_size + w * 0.04;
|
|
||||||
info_w := w - info_x - w * 0.06;
|
|
||||||
|
|
||||||
if has_track {
|
|
||||||
img := image_request(app.current_track.album_id, .LARGE);
|
|
||||||
draw_image(art_x, art_y, art_size, art_size, img);
|
|
||||||
} else {
|
|
||||||
// Placeholder slot so the layout doesn't shift on first launch.
|
|
||||||
Simp.set_shader_for_color();
|
|
||||||
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track name (large).
|
|
||||||
text := ifx has_track app.current_track.name else "— silence —";
|
|
||||||
{
|
|
||||||
label_theme := app.theme.label_theme;
|
|
||||||
label_theme.font = app.title_font;
|
|
||||||
label_theme.alignment = .Left;
|
|
||||||
label_theme.text_color = .{1, 0.4, 0.8, 1};
|
|
||||||
r := get_rect(info_x, art_y, info_w, app.title_font.character_height * 1.4);
|
|
||||||
label(r, text, *label_theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
if has_track {
|
|
||||||
label_theme := app.theme.label_theme;
|
|
||||||
label_theme.font = app.big_font;
|
|
||||||
label_theme.alignment = .Left;
|
|
||||||
label_theme.text_color = .{0.8, 0.8, 1, 1};
|
|
||||||
r := get_rect(info_x, art_y + app.title_font.character_height * 1.5, info_w, app.big_font.character_height * 1.4);
|
|
||||||
label(r, app.current_track.artist, *label_theme);
|
|
||||||
|
|
||||||
label_theme.font = app.body_font;
|
|
||||||
label_theme.text_color = .{0.6, 0.6, 0.8, 1};
|
|
||||||
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
|
|
||||||
label(r, app.current_track.album, *label_theme);
|
|
||||||
|
|
||||||
// Format chip.
|
|
||||||
label_theme.text_color = .{0.5, 0.9, 0.7, 1};
|
|
||||||
format_text := tprint("[%]", app.current_format);
|
|
||||||
r = get_rect(info_x, art_y + app.title_font.character_height * 1.5 + app.big_font.character_height * 1.6 + app.body_font.character_height * 1.6, info_w, app.body_font.character_height * 1.5);
|
|
||||||
label(r, format_text, *label_theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek slider — replaces the old hand-drawn position strip.
|
|
||||||
if has_track && app.current_stream && app.current_stream.sound_data {
|
|
||||||
rate := cast(float) app.current_stream.sound_data.sampling_rate;
|
|
||||||
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
|
|
||||||
if total <= 0 total = max(cast(float) app.current_stream.play_cursor / rate + 1, 1);
|
|
||||||
|
|
||||||
// Each frame, mirror play_cursor → scrub_seconds before the slider
|
|
||||||
// draws. If the user drags, the slider mutates scrub_seconds in
|
|
||||||
// place; we read `changed` and seek.
|
|
||||||
app.scrub_seconds = clamp(cast(float) app.current_stream.play_cursor / rate, 0, total);
|
|
||||||
|
|
||||||
slider_theme := app.theme.slider_theme;
|
|
||||||
slider_theme.foreground.font = app.button_font;
|
|
||||||
|
|
||||||
slider_w := w * 0.7;
|
|
||||||
slider_h := k * 0.6;
|
|
||||||
slider_x := (w - slider_w) * 0.5;
|
|
||||||
slider_y := h * 0.66;
|
|
||||||
|
|
||||||
changed, _ := slider(
|
|
||||||
get_rect(slider_x, slider_y, slider_w, slider_h),
|
|
||||||
*app.scrub_seconds,
|
|
||||||
0.0, total, 1.0,
|
|
||||||
*slider_theme,
|
|
||||||
"", "s",
|
|
||||||
);
|
|
||||||
if changed {
|
|
||||||
audio_seek_seconds(app.scrub_seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time labels under the slider.
|
|
||||||
time_theme := app.theme.label_theme;
|
|
||||||
time_theme.font = app.body_font;
|
|
||||||
time_theme.text_color = .{0.7, 0.7, 0.85, 1};
|
|
||||||
time_theme.alignment = .Left;
|
|
||||||
label(get_rect(slider_x, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
|
|
||||||
format_seconds(app.scrub_seconds), *time_theme);
|
|
||||||
time_theme.alignment = .Right;
|
|
||||||
label(get_rect(slider_x + slider_w * 0.5, slider_y + slider_h, slider_w * 0.5, app.body_font.character_height * 1.3),
|
|
||||||
format_seconds(total), *time_theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport buttons cluster, centered.
|
|
||||||
button_theme := app.theme.button_theme;
|
|
||||||
button_theme.font = app.button_font;
|
|
||||||
button_theme.label_theme.alignment = .Center;
|
|
||||||
|
|
||||||
bw := k * 2.6;
|
|
||||||
bh := k * 1.6;
|
|
||||||
spacing := k * 0.4;
|
|
||||||
total_btn_w := bw * 3 + spacing * 2;
|
|
||||||
bx := (w - total_btn_w) * 0.5;
|
|
||||||
by := h * 0.78;
|
|
||||||
|
|
||||||
if button(get_rect(bx, by, bw, bh), "<<", *button_theme, identifier=1) queue_prev();
|
|
||||||
bx += bw + spacing;
|
|
||||||
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
|
|
||||||
if button(get_rect(bx, by, bw, bh), pp_label, *button_theme, identifier=2) audio_toggle_pause();
|
|
||||||
bx += bw + spacing;
|
|
||||||
if button(get_rect(bx, by, bw, bh), ">>", *button_theme, identifier=3) queue_next();
|
|
||||||
|
|
||||||
// Volume slider, bottom-right.
|
|
||||||
{
|
|
||||||
slider_theme := app.theme.slider_theme;
|
|
||||||
slider_theme.foreground.font = app.body_font;
|
|
||||||
|
|
||||||
vw := w * 0.18;
|
|
||||||
vh := k * 0.55;
|
|
||||||
vx := w - vw - k * 0.6;
|
|
||||||
vy := h - vh - k * 0.6;
|
|
||||||
|
|
||||||
before := app.master_volume;
|
|
||||||
slider(
|
|
||||||
get_rect(vx, vy, vw, vh),
|
|
||||||
*app.master_volume,
|
|
||||||
0.0, 1.0, 0.05,
|
|
||||||
*slider_theme,
|
|
||||||
"vol ", "",
|
|
||||||
identifier=99,
|
|
||||||
);
|
|
||||||
if before != app.master_volume {
|
|
||||||
// Persist on every change — config_save is cheap and the user
|
|
||||||
// expects volume to survive restarts.
|
|
||||||
config_save();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#scope_file
|
#scope_file
|
||||||
|
|
||||||
|
// ── Shared backdrop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw_now_playing_backdrop :: (w: float, h: float, has_track: bool) {
|
||||||
|
if has_track && app.current_track.artist_id.count > 0 {
|
||||||
|
backdrop := image_request(app.current_track.artist_id, .BACKDROP);
|
||||||
|
if backdrop && backdrop.failed {
|
||||||
|
backdrop = image_request(app.current_track.artist_id, .LARGE);
|
||||||
|
}
|
||||||
|
if backdrop && backdrop.loaded {
|
||||||
|
draw_image_cover(0, 0, w, h, backdrop, tint=Vector4.{1, 1, 1, 0.5});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Visualizer on top of backdrop. Background quad at 50% so the artist
|
||||||
|
// image shows through, bars drawn fully opaque on top.
|
||||||
|
gfx_draw_visualizer_background(w, h, bg_alpha=0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared transport ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw_now_playing_transport :: (cx: float, by: float, bw: float, bh: float, sp: float, has_track: bool, font: *Simp.Dynamic_Font = null) {
|
||||||
|
bt := app.theme.button_theme;
|
||||||
|
bt.font = ifx font then font else app.button_font;
|
||||||
|
bt.label_theme.alignment = .Center;
|
||||||
|
|
||||||
|
total_btn_w := bw * 5 + sp * 4;
|
||||||
|
bx := cx - total_btn_w * 0.5;
|
||||||
|
|
||||||
|
shuf_label := ifx queue.shuffle_mode == .OFF then "seq" else ifx queue.shuffle_mode == .ALL then "shuf" else "shuf*";
|
||||||
|
if button(get_rect(bx, by, bw, bh), shuf_label, *bt, identifier=4) {
|
||||||
|
if queue.shuffle_mode == .OFF queue_set_shuffle(.ALL);
|
||||||
|
else if queue.shuffle_mode == .ALL queue_set_shuffle(.FAVOURITES);
|
||||||
|
else queue_set_shuffle(.OFF);
|
||||||
|
}
|
||||||
|
bx += bw + sp;
|
||||||
|
if button(get_rect(bx, by, bw, bh), "<<", *bt, identifier=1) queue_prev();
|
||||||
|
bx += bw + sp;
|
||||||
|
pp_label := ifx audio_is_paused() then "PLAY" else "PAUSE";
|
||||||
|
if button(get_rect(bx, by, bw, bh), pp_label, *bt, identifier=2) audio_toggle_pause();
|
||||||
|
bx += bw + sp;
|
||||||
|
if button(get_rect(bx, by, bw, bh), ">>", *bt, identifier=3) queue_next();
|
||||||
|
bx += bw + sp;
|
||||||
|
|
||||||
|
fav_bt := bt;
|
||||||
|
if has_track && app.current_track.is_favourite {
|
||||||
|
fav_bt.surface_color = .{0.6, 0.05, 0.25, 1};
|
||||||
|
fav_bt.text_color = .{1, 0.7, 0.8, 1};
|
||||||
|
}
|
||||||
|
fav_label := ifx (has_track && app.current_track.is_favourite) then "* fav" else "fav";
|
||||||
|
if button(get_rect(bx, by, bw, bh), fav_label, *fav_bt, identifier=6) && has_track {
|
||||||
|
jellyfin_toggle_favourite(app.current_track.id, app.current_track.is_favourite);
|
||||||
|
app.current_track.is_favourite = !app.current_track.is_favourite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_now_playing_seek :: (slider_x: float, slider_y: float, slider_w: float, slider_h: float, has_track: bool) {
|
||||||
|
if !has_track || !app.current_stream || !app.current_stream.sound_data return;
|
||||||
|
|
||||||
|
rate := cast(float) app.current_stream.sound_data.sampling_rate;
|
||||||
|
total := cast(float) app.current_track.duration_ticks / 10_000_000.0;
|
||||||
|
if total <= 0 total = max(cast(float) app.current_stream.play_cursor / rate + 1, 1);
|
||||||
|
|
||||||
|
app.scrub_seconds = clamp(cast(float) app.current_stream.play_cursor / rate, 0, total);
|
||||||
|
|
||||||
|
st := make_palette_slider_theme(app.button_font);
|
||||||
|
changed, _ := slider(
|
||||||
|
get_rect(slider_x, slider_y, slider_w, slider_h),
|
||||||
|
*app.scrub_seconds, 0.0, total, 30.0, *st, "", "s",
|
||||||
|
);
|
||||||
|
if changed audio_seek_seconds(app.scrub_seconds);
|
||||||
|
|
||||||
|
col_album := now_playing_album_color();
|
||||||
|
time_lt := app.theme.label_theme;
|
||||||
|
time_lt.font = app.body_font;
|
||||||
|
time_lt.text_color = col_album;
|
||||||
|
time_lt.alignment = .Left;
|
||||||
|
label(get_rect(slider_x, slider_y + slider_h + 2, slider_w * 0.5, app.body_font.character_height * 1.3),
|
||||||
|
format_seconds(app.scrub_seconds), *time_lt);
|
||||||
|
time_lt.alignment = .Right;
|
||||||
|
label(get_rect(slider_x + slider_w * 0.5, slider_y + slider_h + 2, slider_w * 0.5, app.body_font.character_height * 1.3),
|
||||||
|
format_seconds(total), *time_lt);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_now_playing_volume :: (vx: float, vy: float, vw: float, vh: float) {
|
||||||
|
vst := make_palette_slider_theme(app.body_font);
|
||||||
|
before := app.master_volume;
|
||||||
|
slider(get_rect(vx, vy, vw, vh), *app.master_volume, 0.0, 1.0, 0.05, *vst, "vol ", "", identifier=99);
|
||||||
|
if before != app.master_volume config_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NORMAL layout (landscape) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw_now_playing_normal :: (w: float, h: float, has_track: bool) {
|
||||||
|
k := h * 0.05;
|
||||||
|
|
||||||
|
{ // Back to library
|
||||||
|
bt := app.theme.button_theme;
|
||||||
|
bt.font = app.button_font;
|
||||||
|
bt.label_theme.alignment = .Center;
|
||||||
|
if button(get_rect(k * 0.6, k * 0.6, w * 0.16, k), "library", *bt)
|
||||||
|
app.current_view = .LIBRARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
art_size := min(w * 0.32, h * 0.52);
|
||||||
|
art_x := w * 0.06;
|
||||||
|
art_y := h * 0.12;
|
||||||
|
info_x := art_x + art_size + w * 0.05;
|
||||||
|
info_w := w - info_x - w * 0.04;
|
||||||
|
|
||||||
|
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
|
||||||
|
else {
|
||||||
|
Simp.set_shader_for_color();
|
||||||
|
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_now_playing_info(info_x, art_y, info_w, has_track, align_center=false);
|
||||||
|
|
||||||
|
draw_now_playing_seek(w * 0.14, h * 0.67, w * 0.72, k * 0.55, has_track);
|
||||||
|
|
||||||
|
draw_now_playing_transport(w * 0.5, h * 0.79, k * 2.4, k * 1.5, k * 0.35, has_track);
|
||||||
|
|
||||||
|
draw_now_playing_volume(w - w * 0.17 - k * 0.5, h - k * 0.5 - k * 0.5, w * 0.17, k * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TALL layout (portrait) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw_now_playing_tall :: (w: float, h: float, has_track: bool) {
|
||||||
|
// In tall/narrow windows, fonts are sized by HEIGHT which makes them huge.
|
||||||
|
// Use row_font for title and body_font for sub-text so everything fits.
|
||||||
|
pad := w * 0.05;
|
||||||
|
|
||||||
|
{
|
||||||
|
bt := app.theme.button_theme;
|
||||||
|
bt.font = app.body_font;
|
||||||
|
bt.label_theme.alignment = .Center;
|
||||||
|
if button(get_rect(pad * 0.8, pad * 0.4, w * 0.30, app.body_font.character_height * 1.6), "library", *bt)
|
||||||
|
app.current_view = .LIBRARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Art — centered, capped so it doesn't dominate
|
||||||
|
art_size := min(w * 0.82, h * 0.38);
|
||||||
|
art_x := (w - art_size) * 0.5;
|
||||||
|
art_y := h * 0.07;
|
||||||
|
|
||||||
|
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
|
||||||
|
else {
|
||||||
|
Simp.set_shader_for_color();
|
||||||
|
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running y cursor — everything stacks below art
|
||||||
|
y := art_y + art_size + h * 0.025;
|
||||||
|
|
||||||
|
// Info: row_font title, body_font sub — avoids the enormous h/8 title
|
||||||
|
y = draw_now_playing_info(pad, y, w - pad * 2, has_track, align_center=true,
|
||||||
|
title_font=app.row_font, sub_font=app.body_font);
|
||||||
|
y += h * 0.02;
|
||||||
|
|
||||||
|
// Seek slider
|
||||||
|
seek_h := app.body_font.character_height * 1.1;
|
||||||
|
draw_now_playing_seek(pad, y, w - pad * 2, seek_h, has_track);
|
||||||
|
y += seek_h + app.body_font.character_height * 1.4 + h * 0.025;
|
||||||
|
|
||||||
|
// Transport: use body_font so "PAUSE" fits in narrow buttons
|
||||||
|
bw := (w - pad * 2 - 4 * pad * 0.5) / 5.0; // 5 buttons filling the width
|
||||||
|
bh := bw * 0.55;
|
||||||
|
sp := pad * 0.5;
|
||||||
|
draw_now_playing_transport(w * 0.5, y, bw, bh, sp, has_track, font=app.body_font);
|
||||||
|
y += bh + h * 0.025;
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
vw := w * 0.65;
|
||||||
|
draw_now_playing_volume((w - vw) * 0.5, y, vw, app.body_font.character_height * 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WIDE layout (ultra-wide) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw_now_playing_wide :: (w: float, h: float, has_track: bool) {
|
||||||
|
k := h * 0.05;
|
||||||
|
|
||||||
|
{ // Back to library
|
||||||
|
bt := app.theme.button_theme;
|
||||||
|
bt.font = app.button_font;
|
||||||
|
bt.label_theme.alignment = .Center;
|
||||||
|
if button(get_rect(k * 0.6, k * 0.6, w * 0.10, k), "library", *bt)
|
||||||
|
app.current_view = .LIBRARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Art — left side, generous size
|
||||||
|
art_size := min(w * 0.28, h * 0.60);
|
||||||
|
art_x := w * 0.05;
|
||||||
|
art_y := (h - art_size) * 0.45;
|
||||||
|
|
||||||
|
if has_track draw_image(art_x, art_y, art_size, art_size, image_request(app.current_track.album_id, .LARGE));
|
||||||
|
else {
|
||||||
|
Simp.set_shader_for_color();
|
||||||
|
Simp.immediate_quad(art_x, art_y, art_x + art_size, art_y + art_size, .{0.10, 0.05, 0.18, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info — center column
|
||||||
|
info_x := art_x + art_size + w * 0.04;
|
||||||
|
info_w := w * 0.42;
|
||||||
|
draw_now_playing_info(info_x, art_y, info_w, has_track, align_center=false);
|
||||||
|
|
||||||
|
// Seek slider — under info
|
||||||
|
seek_y := art_y + art_size * 0.65;
|
||||||
|
draw_now_playing_seek(info_x, seek_y, info_w, k * 0.50, has_track);
|
||||||
|
|
||||||
|
// Transport centered under seek
|
||||||
|
bw := k * 2.2;
|
||||||
|
draw_now_playing_transport(info_x + info_w * 0.5, seek_y + k * 0.9, bw, k * 1.4, k * 0.3, has_track);
|
||||||
|
|
||||||
|
// Volume — bottom right
|
||||||
|
draw_now_playing_volume(w - w * 0.14 - k * 0.5, h - k * 0.5 - k * 0.5, w * 0.14, k * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Info text block ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns the y coordinate just below the last line of text.
|
||||||
|
draw_now_playing_info :: (x: float, y: float, w: float, has_track: bool, align_center: bool,
|
||||||
|
title_font: *Simp.Dynamic_Font = null, sub_font: *Simp.Dynamic_Font = null) -> float {
|
||||||
|
tf := ifx title_font then title_font else app.big_font;
|
||||||
|
sf := ifx sub_font then sub_font else app.row_font;
|
||||||
|
|
||||||
|
col_title := now_playing_title_color();
|
||||||
|
col_artist := now_playing_artist_color();
|
||||||
|
col_album := now_playing_album_color();
|
||||||
|
align := ifx align_center then Text_Alignment.Center else Text_Alignment.Left;
|
||||||
|
|
||||||
|
text := ifx has_track then app.current_track.name else "— silence —";
|
||||||
|
{
|
||||||
|
lt := app.theme.label_theme;
|
||||||
|
lt.font = tf; lt.alignment = align; lt.text_color = col_title;
|
||||||
|
label(get_rect(x, y, w, tf.character_height * 1.4), text, *lt);
|
||||||
|
}
|
||||||
|
if !has_track return y + tf.character_height * 1.4;
|
||||||
|
|
||||||
|
row_y := y + tf.character_height * 1.6;
|
||||||
|
{
|
||||||
|
lt := app.theme.label_theme;
|
||||||
|
lt.font = sf; lt.alignment = align; lt.text_color = col_artist;
|
||||||
|
label(get_rect(x, row_y, w, sf.character_height * 1.4), app.current_track.artist, *lt);
|
||||||
|
row_y += sf.character_height * 1.7;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
lt := app.theme.label_theme;
|
||||||
|
lt.font = app.body_font; lt.alignment = align; lt.text_color = col_album;
|
||||||
|
label(get_rect(x, row_y, w, app.body_font.character_height * 1.4), app.current_track.album, *lt);
|
||||||
|
row_y += app.body_font.character_height * 1.5;
|
||||||
|
}
|
||||||
|
return row_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Palette colour helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
now_playing_title_color :: () -> Vector4 {
|
||||||
|
return ifx app.palette_ready then app.palette[1] else Vector4.{1.0, 0.4, 0.8, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
now_playing_artist_color :: () -> Vector4 {
|
||||||
|
if !app.palette_ready return .{0.8, 0.8, 1.0, 1};
|
||||||
|
p := app.palette[2];
|
||||||
|
return .{p.x * 0.65 + 0.35, p.y * 0.65 + 0.35, p.z * 0.65 + 0.35, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
now_playing_album_color :: () -> Vector4 {
|
||||||
|
if !app.palette_ready return .{0.6, 0.6, 0.8, 1};
|
||||||
|
p := app.palette[3];
|
||||||
|
return .{p.x * 0.55 + 0.35, p.y * 0.55 + 0.35, p.z * 0.55 + 0.35, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slider theme ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
make_palette_slider_theme :: (font: *Simp.Dynamic_Font) -> Slider_Theme {
|
||||||
|
hi := ifx app.palette_ready then app.palette[1] else Vector4.{0.0, 0.65, 0.45, 1};
|
||||||
|
dark := ifx app.palette_ready
|
||||||
|
then Vector4.{app.palette[0].x * 1.3, app.palette[0].y * 1.3, app.palette[0].z * 1.3, 1}
|
||||||
|
else Vector4.{0.0, 0.10, 0.06, 1};
|
||||||
|
hi2 := Vector4.{min(hi.x + 0.12, 1), min(hi.y + 0.12, 1), min(hi.z + 0.12, 1), 1};
|
||||||
|
dk2 := Vector4.{dark.x * 1.6, dark.y * 1.6, dark.z * 1.6, 1};
|
||||||
|
|
||||||
|
st: Slider_Theme;
|
||||||
|
st.foreground.surface_color = hi;
|
||||||
|
st.foreground.surface_color_over = hi2;
|
||||||
|
st.foreground.surface_color_flash = hi2;
|
||||||
|
st.foreground.surface_color_down = hi;
|
||||||
|
st.foreground.frame_color = Vector4.{hi.x * 0.55, hi.y * 0.55, hi.z * 0.55, 1};
|
||||||
|
st.foreground.frame_color_over = hi;
|
||||||
|
st.foreground.text_color = .{1, 1, 1, 1};
|
||||||
|
st.foreground.font = font;
|
||||||
|
st.background.surface_color = dark;
|
||||||
|
st.background.surface_color_over = dark;
|
||||||
|
st.background.frame_color = dk2;
|
||||||
|
st.background.frame_color_over = dk2;
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
format_seconds :: (s: float) -> string {
|
format_seconds :: (s: float) -> string {
|
||||||
if !(s >= 0) return "0:00"; // catches negatives and NaN
|
if !(s >= 0) return "0:00";
|
||||||
total := cast(int) s;
|
total := cast(int) s;
|
||||||
m := total / 60;
|
m := total / 60;
|
||||||
sec := total - m * 60;
|
sec := total - m * 60;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user