1798 lines
61 KiB
C++
1798 lines
61 KiB
C++
/*
|
|
* main.cpp
|
|
* MontaukOS Music Player — MP3 and WAV playback
|
|
* Copyright (c) 2026 Daniel Hammer
|
|
*/
|
|
|
|
#include <montauk/syscall.h>
|
|
#include <montauk/string.h>
|
|
#include <montauk/heap.h>
|
|
#include <gui/gui.hpp>
|
|
#include <gui/mtk/settings.hpp>
|
|
#include <gui/truetype.hpp>
|
|
#include <gui/svg.hpp>
|
|
|
|
extern "C" {
|
|
#include <string.h>
|
|
#include <stdio.h>
|
|
}
|
|
|
|
#include "minimp3.h"
|
|
#include "visualizer.hpp"
|
|
|
|
using namespace gui;
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
static constexpr int INIT_W = 380;
|
|
static constexpr int INIT_H = 520;
|
|
static constexpr int TOOLBAR_H = 36;
|
|
static constexpr int TOOL_ICON_W = 38;
|
|
static constexpr int TOOL_ICON_H = 26;
|
|
static constexpr int TOOL_ICON_GAP = 8;
|
|
static constexpr int TOOL_ICON_SIZE = 16;
|
|
static constexpr int FONT_SIZE = 18;
|
|
static constexpr int FONT_SIZE_SM = 16;
|
|
static constexpr int FONT_SIZE_LG = 20;
|
|
|
|
static constexpr int LIST_TOP = TOOLBAR_H;
|
|
static constexpr int LIST_ITEM_H = 32;
|
|
static constexpr int LIST_SB_W = 12;
|
|
static constexpr int LIST_SB_MIN_TH = 20;
|
|
static constexpr int INFO_H = 56;
|
|
static constexpr int PROGRESS_H = 24;
|
|
static constexpr int TRANSPORT_H = 44;
|
|
|
|
static constexpr int MAX_FILES = 128;
|
|
static constexpr int MAX_PATH = 256;
|
|
static constexpr int MAX_FILTER = 64;
|
|
|
|
// Decode buffer: one MP3 frame = up to 1152 samples * 2 channels
|
|
static constexpr int PCM_BUF_SAMPLES = 1152 * 2;
|
|
// Audio write chunk size (bytes) — feed in small chunks for responsiveness
|
|
static constexpr int AUDIO_CHUNK = 4096;
|
|
|
|
static constexpr Color BG_COLOR = Color::from_rgb(0xFF, 0xFF, 0xFF);
|
|
static constexpr Color TOOLBAR_BG = Color::from_rgb(0xF5, 0xF5, 0xF5);
|
|
static constexpr Color BORDER_COLOR = Color::from_rgb(0xCC, 0xCC, 0xCC);
|
|
static constexpr Color TEXT_COLOR = Color::from_rgb(0x22, 0x22, 0x22);
|
|
static constexpr Color DIM_TEXT = Color::from_rgb(0x88, 0x88, 0x88);
|
|
static constexpr Color TRACK_BG = Color::from_rgb(0xDD, 0xDD, 0xDD);
|
|
static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF);
|
|
static constexpr Color BTN_BG = Color::from_rgb(0xE8, 0xE8, 0xE8);
|
|
static constexpr Color STOP_COLOR = Color::from_rgb(0xCC, 0x33, 0x33);
|
|
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
|
|
enum class PlayState { Stopped, Playing, Paused };
|
|
enum class RepeatMode { Off, All, One };
|
|
|
|
struct FileEntry {
|
|
char name[128];
|
|
bool is_mp3; // true = mp3, false = wav
|
|
};
|
|
|
|
struct Mp3Frame {
|
|
uint64_t samples_before;
|
|
uint32_t offset;
|
|
uint16_t sample_count;
|
|
uint16_t reserved;
|
|
};
|
|
|
|
struct PlayerState {
|
|
// Window
|
|
int win_w, win_h;
|
|
|
|
// Font
|
|
TrueTypeFont* font;
|
|
|
|
// Directory
|
|
char dir_path[MAX_PATH];
|
|
FileEntry files[MAX_FILES];
|
|
int file_count;
|
|
|
|
// List UI
|
|
int scroll_y;
|
|
int hovered_item;
|
|
int selected_item;
|
|
int filtered_indices[MAX_FILES];
|
|
int filtered_count;
|
|
char filter_text[MAX_FILTER];
|
|
bool filter_active;
|
|
|
|
// Playback
|
|
PlayState play_state;
|
|
int current_track; // index into files[], -1 if none
|
|
bool show_visualizer;
|
|
bool shuffle_enabled;
|
|
RepeatMode repeat_mode;
|
|
uint32_t rng_state;
|
|
|
|
// Audio handle
|
|
int audio_handle;
|
|
|
|
// File data (entire file loaded into memory)
|
|
uint8_t* file_data;
|
|
uint64_t file_size;
|
|
|
|
// MP3 decoder
|
|
mp3dec_t mp3dec;
|
|
uint64_t mp3_offset; // current read position in file_data
|
|
Mp3Frame* mp3_frames;
|
|
int mp3_frame_count;
|
|
int mp3_frame_cap;
|
|
uint64_t mp3_seek_discard_samples;
|
|
int sample_rate;
|
|
int channels;
|
|
|
|
// WAV state
|
|
uint64_t wav_data_offset; // offset to PCM data in file
|
|
uint64_t wav_data_size; // size of PCM data
|
|
uint64_t wav_pos; // current position in PCM data
|
|
|
|
// Timing
|
|
uint64_t total_samples; // total decoded samples (per channel)
|
|
uint64_t played_samples; // samples fed to audio device
|
|
|
|
// PCM buffer for decoded audio
|
|
int16_t pcm_buf[PCM_BUF_SAMPLES];
|
|
int pcm_buf_len; // samples remaining in pcm_buf
|
|
int pcm_buf_pos; // current read position in pcm_buf
|
|
|
|
// Progress dragging
|
|
bool dragging_progress;
|
|
|
|
// List scrollbar
|
|
bool sb_dragging;
|
|
int sb_drag_offset;
|
|
bool sb_hovered;
|
|
int mouse_x;
|
|
int mouse_y;
|
|
|
|
// Transport icons (dark for normal, white for active/colored backgrounds)
|
|
SvgIcon ico_rewind;
|
|
SvgIcon ico_play;
|
|
SvgIcon ico_pause;
|
|
SvgIcon ico_stop;
|
|
SvgIcon ico_forward;
|
|
SvgIcon ico_play_w;
|
|
SvgIcon ico_pause_w;
|
|
SvgIcon ico_stop_w;
|
|
SvgIcon ico_shuffle;
|
|
SvgIcon ico_shuffle_w;
|
|
SvgIcon ico_repeat_all;
|
|
SvgIcon ico_repeat_all_w;
|
|
SvgIcon ico_repeat_one;
|
|
SvgIcon ico_repeat_one_w;
|
|
SvgIcon ico_visualizer;
|
|
SvgIcon ico_visualizer_w;
|
|
|
|
music_visualizer::State visualizer;
|
|
};
|
|
|
|
static PlayerState g;
|
|
static Color g_accent = colors::ACCENT;
|
|
static Color g_accent_dark = Color::from_rgb(0x2A, 0x62, 0xC8);
|
|
static Color g_list_hover = Color::from_rgb(0xE8, 0xF0, 0xFD);
|
|
static Color g_list_playing = Color::from_rgb(0xD0, 0xE2, 0xFB);
|
|
static Color g_list_selected = Color::from_rgb(0xF0, 0xF5, 0xFC);
|
|
|
|
static int visible_items();
|
|
|
|
static Color shade(Color c, int delta) {
|
|
return Color::from_rgb(
|
|
(uint8_t)gui_clamp((int)c.r + delta, 0, 255),
|
|
(uint8_t)gui_clamp((int)c.g + delta, 0, 255),
|
|
(uint8_t)gui_clamp((int)c.b + delta, 0, 255));
|
|
}
|
|
|
|
static Color mix(Color a, Color b, int t) {
|
|
t = gui_clamp(t, 0, 255);
|
|
int inv = 255 - t;
|
|
return Color::from_rgb(
|
|
(uint8_t)((a.r * inv + b.r * t + 127) / 255),
|
|
(uint8_t)((a.g * inv + b.g * t + 127) / 255),
|
|
(uint8_t)((a.b * inv + b.b * t + 127) / 255));
|
|
}
|
|
|
|
static void music_set_accent(Color accent) {
|
|
g_accent = accent;
|
|
g_accent_dark = shade(accent, -28);
|
|
g_list_hover = mix(accent, WHITE, 220);
|
|
g_list_playing = mix(accent, WHITE, 196);
|
|
g_list_selected = mix(accent, WHITE, 236);
|
|
}
|
|
|
|
static void music_load_accent() {
|
|
music_set_accent(mtk::load_system_accent());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pixel helpers
|
|
// ============================================================================
|
|
|
|
static void px_fill(uint32_t* px, int bw, int bh,
|
|
int x, int y, int w, int h, Color c) {
|
|
uint32_t v = c.to_pixel();
|
|
int x0 = x < 0 ? 0 : x, y0 = y < 0 ? 0 : y;
|
|
int x1 = x + w > bw ? bw : x + w;
|
|
int y1 = y + h > bh ? bh : y + h;
|
|
for (int row = y0; row < y1; row++)
|
|
for (int col = x0; col < x1; col++)
|
|
px[row * bw + col] = v;
|
|
}
|
|
|
|
static void px_hline(uint32_t* px, int bw, int bh, int x, int y, int w, Color c) {
|
|
if (y < 0 || y >= bh) return;
|
|
uint32_t v = c.to_pixel();
|
|
int x0 = x < 0 ? 0 : x;
|
|
int x1 = x + w > bw ? bw : x + w;
|
|
for (int col = x0; col < x1; col++)
|
|
px[y * bw + col] = v;
|
|
}
|
|
|
|
static void px_fill_rounded(uint32_t* px, int bw, int bh,
|
|
int x, int y, int w, int h, int r, Color c) {
|
|
uint32_t v = c.to_pixel();
|
|
for (int row = 0; row < h; row++) {
|
|
int dy = y + row;
|
|
if (dy < 0 || dy >= bh) continue;
|
|
for (int col = 0; col < w; col++) {
|
|
int dx = x + col;
|
|
if (dx < 0 || dx >= bw) continue;
|
|
bool skip = false;
|
|
int cx, cy;
|
|
if (col < r && row < r) { cx = r - col - 1; cy = r - row - 1; if (cx*cx + cy*cy >= r*r) skip = true; }
|
|
else if (col >= w - r && row < r) { cx = col - (w - r); cy = r - row - 1; if (cx*cx + cy*cy >= r*r) skip = true; }
|
|
else if (col < r && row >= h - r) { cx = r - col - 1; cy = row - (h - r); if (cx*cx + cy*cy >= r*r) skip = true; }
|
|
else if (col >= w - r && row >= h - r){ cx = col - (w - r); cy = row - (h - r); if (cx*cx + cy*cy >= r*r) skip = true; }
|
|
if (!skip) px[dy * bw + dx] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
static void px_circle(uint32_t* px, int bw, int bh,
|
|
int cx, int cy, int r, Color c) {
|
|
uint32_t v = c.to_pixel();
|
|
for (int dy = -r; dy <= r; dy++) {
|
|
int py = cy + dy;
|
|
if (py < 0 || py >= bh) continue;
|
|
for (int dx = -r; dx <= r; dx++) {
|
|
int ppx = cx + dx;
|
|
if (ppx < 0 || ppx >= bw) continue;
|
|
if (dx * dx + dy * dy <= r * r)
|
|
px[py * bw + ppx] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw a filled triangle (play button shape)
|
|
static void px_triangle_right(uint32_t* px, int bw, int bh,
|
|
int x, int y, int w, int h, Color c) {
|
|
uint32_t v = c.to_pixel();
|
|
for (int row = 0; row < h; row++) {
|
|
int dy = y + row;
|
|
if (dy < 0 || dy >= bh) continue;
|
|
// For a right-pointing triangle: width = w * min(row, h-1-row) / (h/2)
|
|
int half = h / 2;
|
|
int span;
|
|
if (half == 0) span = w;
|
|
else span = w * (row <= half ? row : h - 1 - row) / half;
|
|
for (int col = 0; col < span; col++) {
|
|
int dx = x + col;
|
|
if (dx >= 0 && dx < bw)
|
|
px[dy * bw + dx] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
static void px_icon(uint32_t* px, int bw, int bh,
|
|
int x, int y, const SvgIcon& ic) {
|
|
if (!ic.pixels) return;
|
|
for (int row = 0; row < ic.height; row++) {
|
|
int dy = y + row;
|
|
if (dy < 0 || dy >= bh) continue;
|
|
for (int col = 0; col < ic.width; col++) {
|
|
int dx = x + col;
|
|
if (dx < 0 || dx >= bw) continue;
|
|
uint32_t src = ic.pixels[row * ic.width + col];
|
|
uint8_t sa = (src >> 24) & 0xFF;
|
|
if (sa == 0) continue;
|
|
if (sa == 255) {
|
|
px[dy * bw + dx] = src;
|
|
} else {
|
|
uint32_t dst = px[dy * bw + dx];
|
|
uint32_t a = sa, inv_a = 255 - sa;
|
|
uint32_t rr = (a * ((src >> 16) & 0xFF) + inv_a * ((dst >> 16) & 0xFF) + 128) / 255;
|
|
uint32_t gg = (a * ((src >> 8) & 0xFF) + inv_a * ((dst >> 8) & 0xFF) + 128) / 255;
|
|
uint32_t bb = (a * (src & 0xFF) + inv_a * (dst & 0xFF) + 128) / 255;
|
|
px[dy * bw + dx] = 0xFF000000 | (rr << 16) | (gg << 8) | bb;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static void px_text(uint32_t* px, int bw, int bh,
|
|
int x, int y, const char* text, Color c, int size = FONT_SIZE) {
|
|
if (g.font)
|
|
g.font->draw_to_buffer(px, bw, bh, x, y, text, c, size);
|
|
}
|
|
|
|
static int text_w(const char* text, int size = FONT_SIZE) {
|
|
return g.font ? g.font->measure_text(text, size) : 0;
|
|
}
|
|
|
|
static int font_h(int size = FONT_SIZE) {
|
|
if (!g.font) return 16;
|
|
auto* cache = g.font->get_cache(size);
|
|
return cache->ascent - cache->descent;
|
|
}
|
|
|
|
static void px_button(uint32_t* px, int bw, int bh,
|
|
int x, int y, int w, int h,
|
|
const char* label, Color bg, Color fg, int r,
|
|
int size = FONT_SIZE) {
|
|
px_fill_rounded(px, bw, bh, x, y, w, h, r, bg);
|
|
int tw = text_w(label, size);
|
|
int fh = font_h(size);
|
|
px_text(px, bw, bh, x + (w - tw) / 2, y + (h - fh) / 2, label, fg, size);
|
|
}
|
|
|
|
static void px_icon_button(uint32_t* px, int bw, int bh,
|
|
int x, int y, int w, int h,
|
|
const SvgIcon& ic, Color bg, int r) {
|
|
px_fill_rounded(px, bw, bh, x, y, w, h, r, bg);
|
|
int ix = x + (w - ic.width) / 2;
|
|
int iy = y + (h - ic.height) / 2;
|
|
px_icon(px, bw, bh, ix, iy, ic);
|
|
}
|
|
|
|
// ============================================================================
|
|
// String helpers
|
|
// ============================================================================
|
|
|
|
static bool str_ends_with(const char* s, const char* suffix) {
|
|
int sl = montauk::slen(s);
|
|
int xl = montauk::slen(suffix);
|
|
if (xl > sl) return false;
|
|
for (int i = 0; i < xl; i++) {
|
|
char a = s[sl - xl + i];
|
|
char b = suffix[i];
|
|
// case-insensitive
|
|
if (a >= 'A' && a <= 'Z') a += 32;
|
|
if (b >= 'A' && b <= 'Z') b += 32;
|
|
if (a != b) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* repeat_mode_label() {
|
|
switch (g.repeat_mode) {
|
|
case RepeatMode::All: return "Rpt All";
|
|
case RepeatMode::One: return "Rpt 1";
|
|
default: return "Rpt Off";
|
|
}
|
|
}
|
|
|
|
static void cycle_repeat_mode() {
|
|
switch (g.repeat_mode) {
|
|
case RepeatMode::Off: g.repeat_mode = RepeatMode::All; break;
|
|
case RepeatMode::All: g.repeat_mode = RepeatMode::One; break;
|
|
case RepeatMode::One: g.repeat_mode = RepeatMode::Off; break;
|
|
}
|
|
}
|
|
|
|
static uint32_t next_random_u32() {
|
|
if (g.rng_state == 0) g.rng_state = 0xA341316Cu;
|
|
uint32_t x = g.rng_state;
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
g.rng_state = x;
|
|
return x;
|
|
}
|
|
|
|
static Rect repeat_button_rect() {
|
|
int y = (TOOLBAR_H - TOOL_ICON_H) / 2;
|
|
return {g.win_w - 12 - TOOL_ICON_W, y, TOOL_ICON_W, TOOL_ICON_H};
|
|
}
|
|
|
|
static Rect shuffle_button_rect() {
|
|
Rect repeat = repeat_button_rect();
|
|
return {repeat.x - TOOL_ICON_GAP - TOOL_ICON_W, repeat.y, TOOL_ICON_W, TOOL_ICON_H};
|
|
}
|
|
|
|
static Rect visualizer_button_rect() {
|
|
Rect shuffle = shuffle_button_rect();
|
|
return {shuffle.x - TOOL_ICON_GAP - TOOL_ICON_W, shuffle.y, TOOL_ICON_W, TOOL_ICON_H};
|
|
}
|
|
|
|
static char ascii_lower(char c) {
|
|
return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c;
|
|
}
|
|
|
|
static bool is_printable_ascii(char c) {
|
|
return c >= 32 && c < 127;
|
|
}
|
|
|
|
static bool str_contains_case_insensitive(const char* text, const char* needle) {
|
|
if (!needle || !needle[0]) return true;
|
|
if (!text || !text[0]) return false;
|
|
|
|
int text_len = montauk::slen(text);
|
|
int needle_len = montauk::slen(needle);
|
|
if (needle_len > text_len) return false;
|
|
|
|
for (int i = 0; i <= text_len - needle_len; i++) {
|
|
bool match = true;
|
|
for (int j = 0; j < needle_len; j++) {
|
|
if (ascii_lower(text[i + j]) != ascii_lower(needle[j])) {
|
|
match = false;
|
|
break;
|
|
}
|
|
}
|
|
if (match) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static int filtered_raw_at(int visible_index) {
|
|
if (visible_index < 0 || visible_index >= g.filtered_count) return -1;
|
|
return g.filtered_indices[visible_index];
|
|
}
|
|
|
|
static bool filtered_contains(int raw_index) {
|
|
if (raw_index < 0) return false;
|
|
for (int i = 0; i < g.filtered_count; i++) {
|
|
if (g.filtered_indices[i] == raw_index) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static int selected_visible_index() {
|
|
for (int i = 0; i < g.filtered_count; i++) {
|
|
if (g.filtered_indices[i] == g.selected_item) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static void clamp_list_scroll() {
|
|
int max_scroll = g.filtered_count - visible_items();
|
|
if (max_scroll < 0) max_scroll = 0;
|
|
if (g.scroll_y < 0) g.scroll_y = 0;
|
|
if (g.scroll_y > max_scroll) g.scroll_y = max_scroll;
|
|
}
|
|
|
|
static void ensure_selected_visible() {
|
|
int sel = selected_visible_index();
|
|
if (sel < 0) {
|
|
clamp_list_scroll();
|
|
return;
|
|
}
|
|
|
|
int visible = visible_items();
|
|
if (visible <= 0) {
|
|
g.scroll_y = 0;
|
|
return;
|
|
}
|
|
|
|
if (sel < g.scroll_y) g.scroll_y = sel;
|
|
if (sel >= g.scroll_y + visible) g.scroll_y = sel - visible + 1;
|
|
clamp_list_scroll();
|
|
}
|
|
|
|
static void rebuild_filtered_view() {
|
|
int prev_selected = g.selected_item;
|
|
g.filtered_count = 0;
|
|
|
|
for (int i = 0; i < g.file_count && g.filtered_count < MAX_FILES; i++) {
|
|
if (str_contains_case_insensitive(g.files[i].name, g.filter_text)) {
|
|
g.filtered_indices[g.filtered_count++] = i;
|
|
}
|
|
}
|
|
|
|
if (!filtered_contains(g.hovered_item)) g.hovered_item = -1;
|
|
|
|
if (g.filtered_count <= 0) {
|
|
g.selected_item = -1;
|
|
g.scroll_y = 0;
|
|
return;
|
|
}
|
|
|
|
if (filtered_contains(prev_selected)) {
|
|
g.selected_item = prev_selected;
|
|
} else if (filtered_contains(g.current_track)) {
|
|
g.selected_item = g.current_track;
|
|
} else {
|
|
g.selected_item = g.filtered_indices[0];
|
|
}
|
|
|
|
clamp_list_scroll();
|
|
ensure_selected_visible();
|
|
}
|
|
|
|
static void append_filter_char(char c) {
|
|
int len = montauk::slen(g.filter_text);
|
|
if (len >= MAX_FILTER - 1) return;
|
|
g.filter_text[len] = c;
|
|
g.filter_text[len + 1] = '\0';
|
|
rebuild_filtered_view();
|
|
}
|
|
|
|
static void pop_filter_char() {
|
|
int len = montauk::slen(g.filter_text);
|
|
if (len <= 0) return;
|
|
g.filter_text[len - 1] = '\0';
|
|
rebuild_filtered_view();
|
|
}
|
|
|
|
static void clear_filter() {
|
|
if (!g.filter_text[0]) return;
|
|
g.filter_text[0] = '\0';
|
|
rebuild_filtered_view();
|
|
}
|
|
|
|
static void move_selection(int delta) {
|
|
if (g.filtered_count <= 0) {
|
|
g.selected_item = -1;
|
|
g.scroll_y = 0;
|
|
return;
|
|
}
|
|
|
|
int sel = selected_visible_index();
|
|
if (sel < 0) {
|
|
sel = delta >= 0 ? 0 : g.filtered_count - 1;
|
|
} else {
|
|
sel += delta;
|
|
if (sel < 0) sel = 0;
|
|
if (sel >= g.filtered_count) sel = g.filtered_count - 1;
|
|
}
|
|
|
|
g.selected_item = g.filtered_indices[sel];
|
|
ensure_selected_visible();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Directory scanning
|
|
// ============================================================================
|
|
|
|
static void scan_directory() {
|
|
g.file_count = 0;
|
|
|
|
const char* names[256];
|
|
int count = montauk::readdir(g.dir_path, names, 256);
|
|
|
|
for (int i = 0; i < count && g.file_count < MAX_FILES; i++) {
|
|
bool is_mp3 = str_ends_with(names[i], ".mp3");
|
|
bool is_wav = str_ends_with(names[i], ".wav");
|
|
if (!is_mp3 && !is_wav) continue;
|
|
|
|
FileEntry& f = g.files[g.file_count];
|
|
int len = montauk::slen(names[i]);
|
|
if (len >= (int)sizeof(f.name)) len = sizeof(f.name) - 1;
|
|
montauk::memcpy(f.name, names[i], len);
|
|
f.name[len] = 0;
|
|
f.is_mp3 = is_mp3;
|
|
g.file_count++;
|
|
}
|
|
|
|
rebuild_filtered_view();
|
|
}
|
|
|
|
// ============================================================================
|
|
// WAV parser
|
|
// ============================================================================
|
|
|
|
struct WavHeader {
|
|
uint32_t riff_id;
|
|
uint32_t file_size;
|
|
uint32_t wave_id;
|
|
};
|
|
|
|
struct WavChunk {
|
|
uint32_t id;
|
|
uint32_t size;
|
|
};
|
|
|
|
struct WavFmt {
|
|
uint16_t audio_format;
|
|
uint16_t channels;
|
|
uint32_t sample_rate;
|
|
uint32_t byte_rate;
|
|
uint16_t block_align;
|
|
uint16_t bits_per_sample;
|
|
};
|
|
|
|
static bool parse_wav(const uint8_t* data, uint64_t size) {
|
|
if (size < 44) return false;
|
|
|
|
auto* hdr = (const WavHeader*)data;
|
|
if (hdr->riff_id != 0x46464952) return false; // "RIFF"
|
|
if (hdr->wave_id != 0x45564157) return false; // "WAVE"
|
|
|
|
uint64_t pos = 12;
|
|
const WavFmt* fmt = nullptr;
|
|
|
|
while (pos + 8 <= size) {
|
|
auto* chunk = (const WavChunk*)(data + pos);
|
|
uint64_t chunk_data = pos + 8;
|
|
|
|
if (chunk->id == 0x20746D66) { // "fmt "
|
|
if (chunk->size < sizeof(WavFmt)) return false;
|
|
fmt = (const WavFmt*)(data + chunk_data);
|
|
} else if (chunk->id == 0x61746164) { // "data"
|
|
if (!fmt) return false;
|
|
if (fmt->audio_format != 1) return false; // PCM only
|
|
if (fmt->bits_per_sample != 16) return false; // 16-bit PCM only
|
|
g.sample_rate = fmt->sample_rate;
|
|
g.channels = fmt->channels;
|
|
g.wav_data_offset = chunk_data;
|
|
g.wav_data_size = chunk->size;
|
|
g.wav_pos = 0;
|
|
int bytes_per_sample = (fmt->bits_per_sample / 8) * fmt->channels;
|
|
if (bytes_per_sample > 0)
|
|
g.total_samples = chunk->size / bytes_per_sample;
|
|
return true;
|
|
}
|
|
|
|
pos = chunk_data + ((chunk->size + 1) & ~1u); // align to 2 bytes
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ============================================================================
|
|
// MP3 helpers
|
|
// ============================================================================
|
|
|
|
static void free_mp3_index() {
|
|
if (g.mp3_frames) {
|
|
montauk::mfree(g.mp3_frames);
|
|
g.mp3_frames = nullptr;
|
|
}
|
|
g.mp3_frame_count = 0;
|
|
g.mp3_frame_cap = 0;
|
|
g.mp3_seek_discard_samples = 0;
|
|
}
|
|
|
|
static bool push_mp3_frame(uint32_t offset, uint64_t samples_before, uint16_t sample_count) {
|
|
if (g.mp3_frame_count >= g.mp3_frame_cap) {
|
|
int new_cap = g.mp3_frame_cap ? g.mp3_frame_cap * 2 : 1024;
|
|
auto* new_frames = (Mp3Frame*)montauk::realloc(g.mp3_frames,
|
|
(uint64_t)new_cap * sizeof(Mp3Frame));
|
|
if (!new_frames) return false;
|
|
g.mp3_frames = new_frames;
|
|
g.mp3_frame_cap = new_cap;
|
|
}
|
|
|
|
Mp3Frame& frame = g.mp3_frames[g.mp3_frame_count++];
|
|
frame.samples_before = samples_before;
|
|
frame.offset = offset;
|
|
frame.sample_count = sample_count;
|
|
frame.reserved = 0;
|
|
return true;
|
|
}
|
|
|
|
static bool build_mp3_index(uint64_t start_offset) {
|
|
free_mp3_index();
|
|
g.total_samples = 0;
|
|
g.sample_rate = 0;
|
|
g.channels = 0;
|
|
|
|
mp3dec_t scan_dec;
|
|
mp3dec_init(&scan_dec);
|
|
|
|
uint64_t offset = start_offset;
|
|
while (offset < g.file_size) {
|
|
mp3dec_frame_info_t info = {};
|
|
int samples = mp3dec_decode_frame(&scan_dec,
|
|
g.file_data + offset,
|
|
(int)(g.file_size - offset),
|
|
nullptr, &info);
|
|
if (info.frame_bytes <= 0) break;
|
|
|
|
if (samples > 0 && info.hz > 0 && info.channels > 0) {
|
|
uint32_t frame_offset = (uint32_t)(offset + (uint64_t)info.frame_offset);
|
|
if (!push_mp3_frame(frame_offset, g.total_samples, (uint16_t)samples)) {
|
|
free_mp3_index();
|
|
return false;
|
|
}
|
|
if (g.sample_rate == 0) {
|
|
g.sample_rate = info.hz;
|
|
g.channels = info.channels;
|
|
}
|
|
g.total_samples += (uint64_t)samples;
|
|
}
|
|
|
|
offset += (uint64_t)info.frame_bytes;
|
|
}
|
|
|
|
if (g.mp3_frame_count <= 0 || g.sample_rate <= 0 || g.channels <= 0) {
|
|
free_mp3_index();
|
|
return false;
|
|
}
|
|
|
|
g.mp3_offset = g.mp3_frames[0].offset;
|
|
return true;
|
|
}
|
|
|
|
static int pick_shuffled_track(int current) {
|
|
if (g.file_count <= 1) return current >= 0 ? current : 0;
|
|
int pick = (int)(next_random_u32() % (uint32_t)g.file_count);
|
|
if (pick == current) {
|
|
pick = (pick + 1 + (int)(next_random_u32() % (uint32_t)(g.file_count - 1))) % g.file_count;
|
|
}
|
|
return pick;
|
|
}
|
|
|
|
static void seek_to_samples(uint64_t target_samples) {
|
|
if (g.play_state == PlayState::Stopped || g.total_samples == 0) return;
|
|
if (target_samples > g.total_samples) target_samples = g.total_samples;
|
|
|
|
g.pcm_buf_len = 0;
|
|
g.pcm_buf_pos = 0;
|
|
|
|
if (g.current_track >= 0 && g.files[g.current_track].is_mp3) {
|
|
if (g.mp3_frame_count <= 0) return;
|
|
int lo = 0;
|
|
int hi = g.mp3_frame_count - 1;
|
|
int best = 0;
|
|
while (lo <= hi) {
|
|
int mid = lo + (hi - lo) / 2;
|
|
if (g.mp3_frames[mid].samples_before <= target_samples) {
|
|
best = mid;
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
|
|
const Mp3Frame& frame = g.mp3_frames[best];
|
|
g.mp3_offset = frame.offset;
|
|
g.played_samples = frame.samples_before;
|
|
g.mp3_seek_discard_samples = target_samples - frame.samples_before;
|
|
mp3dec_init(&g.mp3dec);
|
|
} else {
|
|
int bytes_per_frame = 2 * g.channels;
|
|
g.wav_pos = target_samples * (uint64_t)bytes_per_frame;
|
|
if (g.wav_pos > g.wav_data_size) g.wav_pos = g.wav_data_size;
|
|
g.played_samples = target_samples;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Playback control
|
|
// ============================================================================
|
|
|
|
static void stop_playback() {
|
|
if (g.audio_handle >= 0) {
|
|
montauk::audio_close(g.audio_handle);
|
|
g.audio_handle = -1;
|
|
}
|
|
if (g.file_data) {
|
|
montauk::mfree(g.file_data);
|
|
g.file_data = nullptr;
|
|
}
|
|
free_mp3_index();
|
|
music_visualizer::reset(g.visualizer);
|
|
g.play_state = PlayState::Stopped;
|
|
g.played_samples = 0;
|
|
g.pcm_buf_len = 0;
|
|
g.pcm_buf_pos = 0;
|
|
g.mp3_offset = 0;
|
|
g.wav_pos = 0;
|
|
}
|
|
|
|
static bool start_track(int index) {
|
|
stop_playback();
|
|
|
|
if (index < 0 || index >= g.file_count) return false;
|
|
|
|
// Build full path
|
|
char path[MAX_PATH * 2];
|
|
snprintf(path, sizeof(path), "%s/%s", g.dir_path, g.files[index].name);
|
|
|
|
// Open and read entire file
|
|
int fh = montauk::open(path);
|
|
if (fh < 0) return false;
|
|
|
|
g.file_size = montauk::getsize(fh);
|
|
if (g.file_size == 0 || g.file_size > 64 * 1024 * 1024) { // 64 MiB limit
|
|
montauk::close(fh);
|
|
return false;
|
|
}
|
|
|
|
g.file_data = (uint8_t*)montauk::malloc(g.file_size);
|
|
if (!g.file_data) {
|
|
montauk::close(fh);
|
|
return false;
|
|
}
|
|
|
|
// Read in chunks (syscall may have size limits)
|
|
uint64_t off = 0;
|
|
while (off < g.file_size) {
|
|
uint64_t chunk = g.file_size - off;
|
|
if (chunk > 32768) chunk = 32768;
|
|
int rd = montauk::read(fh, g.file_data + off, off, chunk);
|
|
if (rd <= 0) break;
|
|
off += rd;
|
|
}
|
|
montauk::close(fh);
|
|
|
|
if (off < g.file_size) {
|
|
montauk::mfree(g.file_data);
|
|
g.file_data = nullptr;
|
|
return false;
|
|
}
|
|
|
|
// Parse format
|
|
if (g.files[index].is_mp3) {
|
|
mp3dec_init(&g.mp3dec);
|
|
g.mp3_offset = 0;
|
|
|
|
// Skip ID3v2
|
|
if (g.file_size >= 10 && g.file_data[0] == 'I' && g.file_data[1] == 'D' && g.file_data[2] == '3') {
|
|
g.mp3_offset = 10 + (((uint64_t)(g.file_data[6] & 0x7F) << 21) |
|
|
((uint64_t)(g.file_data[7] & 0x7F) << 14) |
|
|
((uint64_t)(g.file_data[8] & 0x7F) << 7) |
|
|
((uint64_t)(g.file_data[9] & 0x7F)));
|
|
}
|
|
|
|
if (!build_mp3_index(g.mp3_offset)) {
|
|
montauk::mfree(g.file_data);
|
|
g.file_data = nullptr;
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!parse_wav(g.file_data, g.file_size)) {
|
|
montauk::mfree(g.file_data);
|
|
g.file_data = nullptr;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Open audio device
|
|
g.audio_handle = montauk::audio_open(g.sample_rate, g.channels, 16);
|
|
if (g.audio_handle < 0) {
|
|
free_mp3_index();
|
|
montauk::mfree(g.file_data);
|
|
g.file_data = nullptr;
|
|
return false;
|
|
}
|
|
|
|
g.current_track = index;
|
|
g.play_state = PlayState::Playing;
|
|
g.played_samples = 0;
|
|
g.pcm_buf_len = 0;
|
|
g.pcm_buf_pos = 0;
|
|
g.mp3_seek_discard_samples = 0;
|
|
music_visualizer::reset(g.visualizer);
|
|
|
|
if (filtered_contains(index)) {
|
|
g.selected_item = index;
|
|
} else if (g.filtered_count > 0 && selected_visible_index() < 0) {
|
|
g.selected_item = g.filtered_indices[0];
|
|
}
|
|
ensure_selected_visible();
|
|
|
|
return true;
|
|
}
|
|
|
|
static void toggle_pause() {
|
|
if (g.play_state == PlayState::Playing) {
|
|
montauk::audio_pause(g.audio_handle);
|
|
g.play_state = PlayState::Paused;
|
|
} else if (g.play_state == PlayState::Paused) {
|
|
montauk::audio_resume(g.audio_handle);
|
|
g.play_state = PlayState::Playing;
|
|
}
|
|
}
|
|
|
|
static bool play_selected_or_default() {
|
|
if (g.selected_item >= 0 && filtered_contains(g.selected_item)) return start_track(g.selected_item);
|
|
if (g.filtered_count > 0) return start_track(g.filtered_indices[0]);
|
|
if (g.filter_text[0]) return false;
|
|
if (g.selected_item >= 0) return start_track(g.selected_item);
|
|
if (g.current_track >= 0) return start_track(g.current_track);
|
|
if (g.file_count > 0) return start_track(0);
|
|
return false;
|
|
}
|
|
|
|
static bool next_track(bool auto_advanced = false) {
|
|
if (g.file_count == 0) return false;
|
|
if (g.current_track < 0) return start_track(0);
|
|
|
|
if (auto_advanced && g.repeat_mode == RepeatMode::One) {
|
|
return start_track(g.current_track);
|
|
}
|
|
|
|
int next = -1;
|
|
if (g.shuffle_enabled) {
|
|
next = pick_shuffled_track(g.current_track);
|
|
} else if (g.current_track + 1 < g.file_count) {
|
|
next = g.current_track + 1;
|
|
} else if (g.repeat_mode == RepeatMode::All) {
|
|
next = 0;
|
|
}
|
|
|
|
if (next >= 0) return start_track(next);
|
|
stop_playback();
|
|
return false;
|
|
}
|
|
|
|
static void prev_track() {
|
|
if (g.file_count == 0) return;
|
|
// If we're more than 3 seconds in, restart current track
|
|
if (g.sample_rate > 0 && g.played_samples > (uint64_t)g.sample_rate * 3) {
|
|
start_track(g.current_track);
|
|
return;
|
|
}
|
|
int prev = g.current_track - 1;
|
|
if (prev < 0) prev = g.file_count - 1;
|
|
start_track(prev);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Audio feeding — call from the main loop
|
|
// ============================================================================
|
|
|
|
static void feed_audio() {
|
|
if (g.play_state != PlayState::Playing) return;
|
|
if (g.audio_handle < 0) return;
|
|
|
|
// Try to feed a chunk of audio data
|
|
for (int iter = 0; iter < 4; iter++) {
|
|
// If we have leftover PCM in the buffer, write it
|
|
if (g.pcm_buf_len > 0) {
|
|
int bytes_avail = g.pcm_buf_len * 2; // 16-bit samples
|
|
int to_write = bytes_avail > AUDIO_CHUNK ? AUDIO_CHUNK : bytes_avail;
|
|
int written = montauk::audio_write(g.audio_handle,
|
|
(const uint8_t*)(g.pcm_buf + g.pcm_buf_pos),
|
|
to_write);
|
|
if (written <= 0) return; // device buffer full, try later
|
|
int samples_written = written / 2;
|
|
g.pcm_buf_pos += samples_written;
|
|
g.pcm_buf_len -= samples_written;
|
|
g.played_samples += samples_written / g.channels;
|
|
continue;
|
|
}
|
|
|
|
// Decode more audio
|
|
g.pcm_buf_pos = 0;
|
|
g.pcm_buf_len = 0;
|
|
|
|
if (g.current_track >= 0 && g.files[g.current_track].is_mp3) {
|
|
// MP3 decode
|
|
if (g.mp3_offset >= g.file_size) {
|
|
// End of file — advance to next track
|
|
next_track(true);
|
|
return;
|
|
}
|
|
|
|
mp3dec_frame_info_t info = {};
|
|
int samples = mp3dec_decode_frame(&g.mp3dec,
|
|
g.file_data + g.mp3_offset,
|
|
(int)(g.file_size - g.mp3_offset),
|
|
g.pcm_buf, &info);
|
|
if (info.frame_bytes > 0) {
|
|
g.mp3_offset += info.frame_bytes;
|
|
} else {
|
|
// Can't decode — end
|
|
next_track(true);
|
|
return;
|
|
}
|
|
|
|
if (samples > 0) {
|
|
if (g.mp3_seek_discard_samples > 0) {
|
|
uint64_t discard = g.mp3_seek_discard_samples;
|
|
if (discard > (uint64_t)samples) discard = (uint64_t)samples;
|
|
g.pcm_buf_pos = (int)(discard * (uint64_t)info.channels);
|
|
g.played_samples += discard;
|
|
g.mp3_seek_discard_samples -= discard;
|
|
samples -= (int)discard;
|
|
}
|
|
g.pcm_buf_len = samples * info.channels;
|
|
if (g.pcm_buf_len > 0) {
|
|
music_visualizer::feed_pcm(g.visualizer,
|
|
g.pcm_buf + g.pcm_buf_pos,
|
|
g.pcm_buf_len / info.channels,
|
|
info.channels,
|
|
info.hz);
|
|
} else {
|
|
g.pcm_buf_pos = 0;
|
|
}
|
|
}
|
|
} else {
|
|
// WAV decode — just copy PCM data
|
|
if (g.wav_pos >= g.wav_data_size) {
|
|
next_track(true);
|
|
return;
|
|
}
|
|
|
|
uint64_t remaining = g.wav_data_size - g.wav_pos;
|
|
uint64_t to_copy = remaining;
|
|
if (to_copy > sizeof(g.pcm_buf)) to_copy = sizeof(g.pcm_buf);
|
|
|
|
memcpy(g.pcm_buf, g.file_data + g.wav_data_offset + g.wav_pos, to_copy);
|
|
g.wav_pos += to_copy;
|
|
g.pcm_buf_len = (int)(to_copy / 2); // 16-bit samples
|
|
if (g.channels > 0 && g.pcm_buf_len > 0) {
|
|
music_visualizer::feed_pcm(g.visualizer,
|
|
g.pcm_buf,
|
|
g.pcm_buf_len / g.channels,
|
|
g.channels,
|
|
g.sample_rate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Time formatting
|
|
// ============================================================================
|
|
|
|
static void format_time(char* buf, int buf_size, uint64_t samples, int rate) {
|
|
if (rate <= 0) { snprintf(buf, buf_size, "--:--"); return; }
|
|
uint64_t secs = samples / rate;
|
|
int m = (int)(secs / 60);
|
|
int s = (int)(secs % 60);
|
|
snprintf(buf, buf_size, "%d:%02d", m, s);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Render
|
|
// ============================================================================
|
|
|
|
static int list_bottom() {
|
|
return g.win_h - INFO_H - PROGRESS_H - TRANSPORT_H;
|
|
}
|
|
|
|
static int visible_items() {
|
|
int count = (list_bottom() - LIST_TOP) / LIST_ITEM_H;
|
|
return count > 0 ? count : 0;
|
|
}
|
|
|
|
static bool list_scrollbar_visible() {
|
|
return g.filtered_count > visible_items() && visible_items() > 0;
|
|
}
|
|
|
|
static void list_scrollbar_rects(int W, Rect* track, Rect* thumb) {
|
|
int list_h = list_bottom() - LIST_TOP;
|
|
int visible = visible_items();
|
|
int tx = W - LIST_SB_W;
|
|
*track = {tx, LIST_TOP, LIST_SB_W, list_h};
|
|
|
|
int max_scroll = g.filtered_count - visible;
|
|
if (max_scroll < 1) max_scroll = 1;
|
|
int th = (visible * list_h) / g.filtered_count;
|
|
if (th < LIST_SB_MIN_TH) th = LIST_SB_MIN_TH;
|
|
if (th > list_h) th = list_h;
|
|
int range = list_h - th;
|
|
int ty = LIST_TOP + (g.scroll_y * range) / max_scroll;
|
|
*thumb = {tx + 1, ty, LIST_SB_W - 2, th};
|
|
}
|
|
|
|
static Rect content_panel_rect() {
|
|
int y = list_bottom() + 1;
|
|
return {0, LIST_TOP, g.win_w, y - LIST_TOP};
|
|
}
|
|
|
|
static void render(uint32_t* pixels) {
|
|
int W = g.win_w, H = g.win_h;
|
|
int fh = font_h();
|
|
int fh_sm = font_h(FONT_SIZE_SM);
|
|
|
|
// Background
|
|
px_fill(pixels, W, H, 0, 0, W, H, BG_COLOR);
|
|
|
|
// Toolbar
|
|
px_fill(pixels, W, H, 0, 0, W, TOOLBAR_H, TOOLBAR_BG);
|
|
px_hline(pixels, W, H, 0, TOOLBAR_H - 1, W, BORDER_COLOR);
|
|
Rect visualizer_rect = visualizer_button_rect();
|
|
Rect shuffle_rect = shuffle_button_rect();
|
|
Rect repeat_rect = repeat_button_rect();
|
|
px_icon_button(pixels, W, H, visualizer_rect.x, visualizer_rect.y,
|
|
visualizer_rect.w, visualizer_rect.h,
|
|
g.show_visualizer ? g.ico_visualizer_w : g.ico_visualizer,
|
|
g.show_visualizer ? g_accent : BTN_BG, 6);
|
|
px_icon_button(pixels, W, H, shuffle_rect.x, shuffle_rect.y, shuffle_rect.w, shuffle_rect.h,
|
|
g.shuffle_enabled ? g.ico_shuffle_w : g.ico_shuffle,
|
|
g.shuffle_enabled ? g_accent : BTN_BG, 6);
|
|
Color repeat_bg = (g.repeat_mode == RepeatMode::Off) ? BTN_BG : g_accent;
|
|
px_icon_button(pixels, W, H, repeat_rect.x, repeat_rect.y, repeat_rect.w, repeat_rect.h,
|
|
(g.repeat_mode == RepeatMode::One) ? g.ico_repeat_one_w :
|
|
(g.repeat_mode == RepeatMode::All) ? g.ico_repeat_all_w :
|
|
g.ico_repeat_all,
|
|
repeat_bg, 6);
|
|
|
|
// Directory path in toolbar
|
|
{
|
|
int max_dir_w = visualizer_rect.x - 20;
|
|
if (g.filter_active || g.filter_text[0]) {
|
|
char filter_query[24];
|
|
char filter_label[48];
|
|
const char* query = g.filter_text;
|
|
int qlen = montauk::slen(query);
|
|
if (qlen > 15) {
|
|
montauk::memcpy(filter_query, "...", 3);
|
|
montauk::memcpy(filter_query + 3, query + qlen - 15, 15);
|
|
filter_query[18] = '\0';
|
|
query = filter_query;
|
|
}
|
|
|
|
if (g.filter_text[0]) {
|
|
snprintf(filter_label, sizeof(filter_label), "%s%s%s",
|
|
g.filter_active ? "Find: " : "Filter: ",
|
|
query,
|
|
g.filter_active ? "_" : "");
|
|
} else {
|
|
snprintf(filter_label, sizeof(filter_label), "%s",
|
|
g.filter_active ? "Find" : "Filter");
|
|
}
|
|
|
|
int pill_h = TOOL_ICON_H;
|
|
int pill_w = text_w(filter_label, FONT_SIZE_SM) + 14;
|
|
int pill_x = visualizer_rect.x - TOOL_ICON_GAP - pill_w;
|
|
int pill_y = (TOOLBAR_H - pill_h) / 2;
|
|
Color pill_bg = g.filter_active ? g_list_hover : BTN_BG;
|
|
Color pill_fg = g.filter_active ? g_accent_dark : DIM_TEXT;
|
|
if (pill_x > 80) {
|
|
px_fill_rounded(pixels, W, H, pill_x, pill_y, pill_w, pill_h, 6, pill_bg);
|
|
px_text(pixels, W, H, pill_x + 7, pill_y + (pill_h - fh_sm) / 2,
|
|
filter_label, pill_fg, FONT_SIZE_SM);
|
|
max_dir_w = pill_x - 12;
|
|
}
|
|
}
|
|
|
|
const char* display_dir = g.dir_path;
|
|
int dw = text_w(display_dir, FONT_SIZE_SM);
|
|
int dir_len = montauk::slen(g.dir_path);
|
|
if (dw > max_dir_w && dir_len > 20) {
|
|
display_dir = g.dir_path + montauk::slen(g.dir_path) - 20;
|
|
}
|
|
px_text(pixels, W, H, 12,
|
|
(TOOLBAR_H - font_h(FONT_SIZE_SM)) / 2, display_dir, TEXT_COLOR, FONT_SIZE_SM);
|
|
}
|
|
|
|
Rect content_panel = content_panel_rect();
|
|
int list_end = list_bottom();
|
|
int visible_count = visible_items();
|
|
px_hline(pixels, W, H, 0, list_end, W, BORDER_COLOR);
|
|
|
|
if (!g.show_visualizer) {
|
|
bool sb_vis = list_scrollbar_visible();
|
|
int row_w = sb_vis ? (W - LIST_SB_W) : W;
|
|
int tag_right = sb_vis ? (W - LIST_SB_W) : W;
|
|
|
|
for (int i = 0; i < visible_count && (i + g.scroll_y) < g.filtered_count; i++) {
|
|
int idx = filtered_raw_at(i + g.scroll_y);
|
|
if (idx < 0) break;
|
|
int iy = LIST_TOP + i * LIST_ITEM_H;
|
|
|
|
Color bg = BG_COLOR;
|
|
if (idx == g.current_track && g.play_state != PlayState::Stopped)
|
|
bg = g_list_playing;
|
|
else if (idx == g.selected_item)
|
|
bg = g_list_selected;
|
|
else if (idx == g.hovered_item)
|
|
bg = g_list_hover;
|
|
|
|
if (bg.r != BG_COLOR.r || bg.g != BG_COLOR.g || bg.b != BG_COLOR.b)
|
|
px_fill(pixels, W, H, 0, iy, row_w, LIST_ITEM_H, bg);
|
|
|
|
if (idx == g.selected_item && !(idx == g.current_track && g.play_state != PlayState::Stopped))
|
|
px_fill(pixels, W, H, 0, iy, 3, LIST_ITEM_H, g_accent);
|
|
|
|
if (idx == g.current_track && g.play_state == PlayState::Playing) {
|
|
px_triangle_right(pixels, W, H, 8, iy + 8, 8, 12, g_accent);
|
|
} else if (idx == g.current_track && g.play_state == PlayState::Paused) {
|
|
px_fill(pixels, W, H, 8, iy + 8, 3, 12, g_accent);
|
|
px_fill(pixels, W, H, 13, iy + 8, 3, 12, g_accent);
|
|
}
|
|
|
|
Color name_color = (idx == g.current_track && g.play_state != PlayState::Stopped)
|
|
? g_accent_dark : TEXT_COLOR;
|
|
px_text(pixels, W, H, 24, iy + (LIST_ITEM_H - fh_sm) / 2,
|
|
g.files[idx].name, name_color, FONT_SIZE_SM);
|
|
|
|
const char* tag = g.files[idx].is_mp3 ? "MP3" : "WAV";
|
|
int tw = text_w(tag, FONT_SIZE_SM);
|
|
px_text(pixels, W, H, tag_right - tw - 12, iy + (LIST_ITEM_H - fh_sm) / 2,
|
|
tag, DIM_TEXT, FONT_SIZE_SM);
|
|
}
|
|
|
|
if (sb_vis) {
|
|
Rect track, thumb;
|
|
list_scrollbar_rects(W, &track, &thumb);
|
|
px_fill(pixels, W, H, track.x, track.y, track.w, track.h,
|
|
Color::from_rgb(0xF0, 0xF0, 0xF0));
|
|
Color tc = (g.sb_hovered || g.sb_dragging)
|
|
? Color::from_rgb(0xA0, 0xA0, 0xA0)
|
|
: Color::from_rgb(0xC0, 0xC0, 0xC0);
|
|
px_fill(pixels, W, H, thumb.x, thumb.y, thumb.w, thumb.h, tc);
|
|
}
|
|
|
|
if (g.filtered_count == 0) {
|
|
const char* msg = g.file_count == 0 ? "No audio files found" : "No matches for filter";
|
|
int mw = text_w(msg);
|
|
int cy = LIST_TOP + (list_end - LIST_TOP) / 2 - fh / 2;
|
|
px_text(pixels, W, H, (W - mw) / 2, cy, msg, DIM_TEXT);
|
|
if (g.file_count > 0 && g.filter_text[0]) {
|
|
char detail[96];
|
|
snprintf(detail, sizeof(detail), "Current filter: %s", g.filter_text);
|
|
int dw = text_w(detail, FONT_SIZE_SM);
|
|
px_text(pixels, W, H, (W - dw) / 2, cy + fh_sm + 6, detail, DIM_TEXT, FONT_SIZE_SM);
|
|
}
|
|
}
|
|
} else {
|
|
px_fill(pixels, W, H, content_panel.x, content_panel.y, content_panel.w, content_panel.h, TOOLBAR_BG);
|
|
Rect viz_rect = {12, content_panel.y + 12, W - 24, content_panel.h - 24};
|
|
music_visualizer::render(pixels, W, H, viz_rect, g.visualizer,
|
|
g_accent, g_accent_dark, TRACK_BG, BORDER_COLOR);
|
|
}
|
|
|
|
// ---- Now Playing info ----
|
|
int info_y = list_end + 1;
|
|
px_fill(pixels, W, H, 0, info_y, W, INFO_H, TOOLBAR_BG);
|
|
px_hline(pixels, W, H, 0, info_y + INFO_H - 1, W, BORDER_COLOR);
|
|
|
|
if (g.current_track >= 0 && g.play_state != PlayState::Stopped) {
|
|
// Track name
|
|
px_text(pixels, W, H, 12, info_y + 8,
|
|
g.files[g.current_track].name, TEXT_COLOR, FONT_SIZE);
|
|
|
|
// Time display
|
|
char time_cur[16], time_total[16], time_str[40];
|
|
format_time(time_cur, sizeof(time_cur), g.played_samples, g.sample_rate);
|
|
format_time(time_total, sizeof(time_total), g.total_samples, g.sample_rate);
|
|
snprintf(time_str, sizeof(time_str), "%s / %s", time_cur, time_total);
|
|
px_text(pixels, W, H, 12, info_y + 8 + fh + 4, time_str, DIM_TEXT, FONT_SIZE_SM);
|
|
|
|
// State indicator on right
|
|
const char* state_str = (g.play_state == PlayState::Playing) ? "Playing" : "Paused";
|
|
int sw = text_w(state_str, FONT_SIZE_SM);
|
|
px_text(pixels, W, H, W - sw - 12, info_y + 8 + fh + 4,
|
|
state_str, g_accent, FONT_SIZE_SM);
|
|
} else {
|
|
const char* msg = "No track selected";
|
|
px_text(pixels, W, H, 12, info_y + (INFO_H - fh) / 2, msg, DIM_TEXT);
|
|
}
|
|
|
|
// ---- Progress bar ----
|
|
int prog_y = info_y + INFO_H;
|
|
px_fill(pixels, W, H, 0, prog_y, W, PROGRESS_H, Color::from_rgb(0xF0, 0xF0, 0xF0));
|
|
|
|
int bar_x = 16, bar_w = W - 32, bar_h = 6;
|
|
int bar_y = prog_y + (PROGRESS_H - bar_h) / 2;
|
|
px_fill_rounded(pixels, W, H, bar_x, bar_y, bar_w, bar_h, 3, TRACK_BG);
|
|
|
|
if (g.total_samples > 0 && g.play_state != PlayState::Stopped) {
|
|
int fill = (int)((g.played_samples * bar_w) / g.total_samples);
|
|
if (fill > bar_w) fill = bar_w;
|
|
if (fill > 0)
|
|
px_fill_rounded(pixels, W, H, bar_x, bar_y, fill, bar_h, 3, g_accent);
|
|
// Knob
|
|
px_circle(pixels, W, H, bar_x + fill, bar_y + bar_h / 2, 5, g_accent);
|
|
px_circle(pixels, W, H, bar_x + fill, bar_y + bar_h / 2, 2, WHITE);
|
|
}
|
|
|
|
// ---- Transport controls ----
|
|
int trans_y = prog_y + PROGRESS_H;
|
|
px_fill(pixels, W, H, 0, trans_y, W, TRANSPORT_H, BG_COLOR);
|
|
|
|
int btn_w = 48, btn_h = 32, btn_r = 6, gap = 8;
|
|
int total_w = btn_w * 4 + gap * 3; // prev, play, stop, next
|
|
int bx = (W - total_w) / 2;
|
|
int by = trans_y + (TRANSPORT_H - btn_h) / 2;
|
|
|
|
// [<<] Prev
|
|
px_icon_button(pixels, W, H, bx, by, btn_w, btn_h, g.ico_rewind, BTN_BG, btn_r);
|
|
bx += btn_w + gap;
|
|
|
|
// [Play/Pause]
|
|
{
|
|
bool active = (g.play_state == PlayState::Playing);
|
|
Color bg = active ? g_accent : BTN_BG;
|
|
const SvgIcon& ic = active ? g.ico_pause_w : g.ico_play;
|
|
px_icon_button(pixels, W, H, bx, by, btn_w, btn_h, ic, bg, btn_r);
|
|
}
|
|
bx += btn_w + gap;
|
|
|
|
// [Stop]
|
|
{
|
|
bool active = (g.play_state != PlayState::Stopped);
|
|
Color bg = active ? STOP_COLOR : BTN_BG;
|
|
const SvgIcon& ic = active ? g.ico_stop_w : g.ico_stop;
|
|
px_icon_button(pixels, W, H, bx, by, btn_w, btn_h, ic, bg, btn_r);
|
|
}
|
|
bx += btn_w + gap;
|
|
|
|
// [>>] Next
|
|
px_icon_button(pixels, W, H, bx, by, btn_w, btn_h, g.ico_forward, BTN_BG, btn_r);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hit testing
|
|
// ============================================================================
|
|
|
|
static bool handle_click(int mx, int my) {
|
|
int W = g.win_w;
|
|
int lb = list_bottom();
|
|
Rect visualizer_rect = visualizer_button_rect();
|
|
Rect shuffle_rect = shuffle_button_rect();
|
|
Rect repeat_rect = repeat_button_rect();
|
|
int info_y = lb + 1;
|
|
int prog_y = info_y + INFO_H;
|
|
int trans_y = prog_y + PROGRESS_H;
|
|
|
|
if (visualizer_rect.contains(mx, my)) {
|
|
g.show_visualizer = !g.show_visualizer;
|
|
return true;
|
|
}
|
|
|
|
if (shuffle_rect.contains(mx, my)) {
|
|
g.shuffle_enabled = !g.shuffle_enabled;
|
|
return true;
|
|
}
|
|
|
|
if (repeat_rect.contains(mx, my)) {
|
|
cycle_repeat_mode();
|
|
return true;
|
|
}
|
|
|
|
// File list click
|
|
if (!g.show_visualizer && my >= LIST_TOP && my < lb && mx >= 0 && mx < W) {
|
|
int idx = filtered_raw_at((my - LIST_TOP) / LIST_ITEM_H + g.scroll_y);
|
|
if (idx >= 0) {
|
|
start_track(idx);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Progress bar click
|
|
if (my >= prog_y && my < prog_y + PROGRESS_H) {
|
|
if (g.play_state != PlayState::Stopped && g.total_samples > 0) {
|
|
int bar_x = 16, bar_w = W - 32;
|
|
int rel = mx - bar_x;
|
|
if (rel < 0) rel = 0;
|
|
if (rel > bar_w) rel = bar_w;
|
|
uint64_t target_samples = (uint64_t)rel * g.total_samples / bar_w;
|
|
seek_to_samples(target_samples);
|
|
g.dragging_progress = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Transport buttons
|
|
if (my >= trans_y && my < trans_y + TRANSPORT_H) {
|
|
int btn_w = 48, btn_h = 32, gap = 8;
|
|
int total_w = btn_w * 4 + gap * 3;
|
|
int bx = (W - total_w) / 2;
|
|
int by = trans_y + (TRANSPORT_H - btn_h) / 2;
|
|
|
|
if (my >= by && my < by + btn_h) {
|
|
// Prev
|
|
if (mx >= bx && mx < bx + btn_w) { prev_track(); return true; }
|
|
bx += btn_w + gap;
|
|
|
|
// Play/Pause
|
|
if (mx >= bx && mx < bx + btn_w) {
|
|
if (g.play_state == PlayState::Stopped) {
|
|
play_selected_or_default();
|
|
} else {
|
|
toggle_pause();
|
|
}
|
|
return true;
|
|
}
|
|
bx += btn_w + gap;
|
|
|
|
// Stop
|
|
if (mx >= bx && mx < bx + btn_w) { stop_playback(); return true; }
|
|
bx += btn_w + gap;
|
|
|
|
// Next
|
|
if (mx >= bx && mx < bx + btn_w) { next_track(false); return true; }
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool handle_key(const montauk::abi::KeyEvent& key, bool& quit) {
|
|
if (!key.pressed) return false;
|
|
|
|
if (key.scancode == 0x01) { // Escape
|
|
if (g.filter_active) {
|
|
if (g.filter_text[0]) clear_filter();
|
|
else g.filter_active = false;
|
|
return true;
|
|
}
|
|
quit = true;
|
|
return false;
|
|
}
|
|
|
|
if (key.ctrl && (key.ascii == 'f' || key.ascii == 'F' || key.ascii == 6)) {
|
|
g.filter_active = true;
|
|
return true;
|
|
}
|
|
|
|
if (!key.ctrl && !key.alt && key.ascii == '/') {
|
|
g.filter_active = true;
|
|
return true;
|
|
}
|
|
|
|
if (!g.show_visualizer && key.scancode == 0x48) { // Up
|
|
move_selection(-1);
|
|
return true;
|
|
}
|
|
if (!g.show_visualizer && key.scancode == 0x50) { // Down
|
|
move_selection(1);
|
|
return true;
|
|
}
|
|
if (!g.show_visualizer &&
|
|
(key.ascii == '\n' || key.ascii == '\r' || key.scancode == 0x1C)) {
|
|
if (g.selected_item >= 0) {
|
|
start_track(g.selected_item);
|
|
g.filter_active = false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (g.filter_active) {
|
|
if (key.ascii == '\b' || key.scancode == 0x0E) {
|
|
if (g.filter_text[0]) pop_filter_char();
|
|
else g.filter_active = false;
|
|
return true;
|
|
}
|
|
if (!key.ctrl && !key.alt && is_printable_ascii(key.ascii)) {
|
|
append_filter_char(key.ascii);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (key.ascii == ' ') {
|
|
if (g.play_state == PlayState::Stopped)
|
|
play_selected_or_default();
|
|
else
|
|
toggle_pause();
|
|
return true;
|
|
}
|
|
if (key.ascii == 's' || key.ascii == 'S') {
|
|
stop_playback();
|
|
return true;
|
|
}
|
|
if (key.ascii == 'h' || key.ascii == 'H') {
|
|
g.shuffle_enabled = !g.shuffle_enabled;
|
|
return true;
|
|
}
|
|
if (key.ascii == 'r' || key.ascii == 'R') {
|
|
cycle_repeat_mode();
|
|
return true;
|
|
}
|
|
if (key.ascii == 'v' || key.ascii == 'V') {
|
|
g.show_visualizer = !g.show_visualizer;
|
|
return true;
|
|
}
|
|
if (key.scancode == 0x4D) { // Right arrow
|
|
next_track(false);
|
|
return true;
|
|
}
|
|
if (key.scancode == 0x4B) { // Left arrow
|
|
prev_track();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Entry point
|
|
// ============================================================================
|
|
|
|
extern "C" void _start() {
|
|
// Init state
|
|
memset(&g, 0, sizeof(g));
|
|
g.win_w = INIT_W;
|
|
g.win_h = INIT_H;
|
|
g.audio_handle = -1;
|
|
g.current_track = -1;
|
|
g.play_state = PlayState::Stopped;
|
|
g.hovered_item = -1;
|
|
g.selected_item = -1;
|
|
g.show_visualizer = false;
|
|
g.repeat_mode = RepeatMode::Off;
|
|
g.rng_state = (uint32_t)montauk::get_milliseconds() ^ 0xC0DEFACEu;
|
|
music_visualizer::reset(g.visualizer);
|
|
|
|
// Parse arguments: accept a directory path or a file path
|
|
// Helper: set dir_path to current user's home directory
|
|
auto set_user_home = [&]() {
|
|
char username[32] = {};
|
|
if (montauk::getuser(username, sizeof(username)) > 0 && username[0]) {
|
|
snprintf(g.dir_path, sizeof(g.dir_path), "0:/users/%s/Music", username);
|
|
} else {
|
|
snprintf(g.dir_path, sizeof(g.dir_path), "0:/users");
|
|
}
|
|
};
|
|
|
|
char arg_file[128] = {};
|
|
{
|
|
char args[256];
|
|
int arglen = montauk::getargs(args, sizeof(args));
|
|
if (arglen > 0 && args[0]) {
|
|
if (str_ends_with(args, ".mp3") || str_ends_with(args, ".wav")) {
|
|
// File path: extract directory and filename
|
|
int last_slash = -1;
|
|
for (int i = 0; args[i]; i++) {
|
|
if (args[i] == '/') last_slash = i;
|
|
}
|
|
if (last_slash > 0) {
|
|
memcpy(g.dir_path, args, last_slash);
|
|
g.dir_path[last_slash] = '\0';
|
|
const char* fname = args + last_slash + 1;
|
|
int flen = montauk::slen(fname);
|
|
if (flen > 0 && flen < (int)sizeof(arg_file)) {
|
|
memcpy(arg_file, fname, flen);
|
|
arg_file[flen] = '\0';
|
|
}
|
|
} else {
|
|
set_user_home();
|
|
}
|
|
} else {
|
|
int len = montauk::slen(args);
|
|
if (len >= (int)sizeof(g.dir_path)) len = sizeof(g.dir_path) - 1;
|
|
memcpy(g.dir_path, args, len);
|
|
g.dir_path[len] = '\0';
|
|
}
|
|
} else {
|
|
set_user_home();
|
|
}
|
|
}
|
|
|
|
music_load_accent();
|
|
|
|
// Load font
|
|
{
|
|
TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont));
|
|
if (f) {
|
|
montauk::memset(f, 0, sizeof(TrueTypeFont));
|
|
if (!f->init("0:/fonts/Roboto-Medium.ttf")) { montauk::mfree(f); f = nullptr; }
|
|
}
|
|
g.font = f;
|
|
}
|
|
|
|
// Load transport icons (dark + white variants for colored backgrounds)
|
|
{
|
|
Color ic_color = TEXT_COLOR;
|
|
g.ico_rewind = svg_load("0:/icons/media-rewind.svg", 16, 16, ic_color);
|
|
g.ico_play = svg_load("0:/icons/media-play.svg", 16, 16, ic_color);
|
|
g.ico_pause = svg_load("0:/icons/media-pause.svg", 16, 16, ic_color);
|
|
g.ico_stop = svg_load("0:/icons/media-stop.svg", 16, 16, ic_color);
|
|
g.ico_forward = svg_load("0:/icons/media-forward.svg", 16, 16, ic_color);
|
|
g.ico_play_w = svg_load("0:/icons/media-play.svg", 16, 16, WHITE);
|
|
g.ico_pause_w = svg_load("0:/icons/media-pause.svg", 16, 16, WHITE);
|
|
g.ico_stop_w = svg_load("0:/icons/media-stop.svg", 16, 16, WHITE);
|
|
g.ico_visualizer = svg_load("0:/icons/view-media-equalizer.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, ic_color);
|
|
g.ico_visualizer_w = svg_load("0:/icons/view-media-equalizer.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, WHITE);
|
|
g.ico_shuffle = svg_load("0:/icons/media-playlist-shuffle.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, ic_color);
|
|
g.ico_shuffle_w = svg_load("0:/icons/media-playlist-shuffle.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, WHITE);
|
|
g.ico_repeat_all = svg_load("0:/icons/media-playlist-repeat.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, ic_color);
|
|
g.ico_repeat_all_w = svg_load("0:/icons/media-playlist-repeat.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, WHITE);
|
|
g.ico_repeat_one = svg_load("0:/icons/media-playlist-repeat-song.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, ic_color);
|
|
g.ico_repeat_one_w = svg_load("0:/icons/media-playlist-repeat-song.svg", TOOL_ICON_SIZE, TOOL_ICON_SIZE, WHITE);
|
|
}
|
|
|
|
// Scan for audio files
|
|
scan_directory();
|
|
|
|
// Auto-size window width so the longest track name fits without truncation.
|
|
// Layout per row: 24px left pad + name + 8px gap + tag ("MP3"/"WAV") + 12px
|
|
// right pad, plus ~10px for the scrollbar gutter.
|
|
{
|
|
int max_name_w = 0;
|
|
for (int i = 0; i < g.file_count; i++) {
|
|
int w = text_w(g.files[i].name, FONT_SIZE_SM);
|
|
if (w > max_name_w) max_name_w = w;
|
|
}
|
|
int tag_w = text_w("MP3", FONT_SIZE_SM);
|
|
int wav_w = text_w("WAV", FONT_SIZE_SM);
|
|
if (wav_w > tag_w) tag_w = wav_w;
|
|
int needed_w = 24 + max_name_w + 8 + tag_w + 12 + 10;
|
|
if (needed_w > g.win_w) g.win_w = needed_w;
|
|
if (g.win_w > 900) g.win_w = 900;
|
|
}
|
|
|
|
// If a specific file was requested, find it and auto-play
|
|
int auto_play_idx = -1;
|
|
if (arg_file[0]) {
|
|
for (int i = 0; i < g.file_count; i++) {
|
|
if (str_ends_with(g.files[i].name, arg_file) &&
|
|
montauk::slen(g.files[i].name) == montauk::slen(arg_file)) {
|
|
auto_play_idx = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create window
|
|
montauk::abi::WinCreateResult wres;
|
|
if (montauk::win_create("Music", g.win_w, g.win_h, &wres) < 0 || wres.id < 0)
|
|
montauk::exit(1);
|
|
|
|
int win_id = wres.id;
|
|
uint32_t* pixels = (uint32_t*)(uintptr_t)wres.pixelVa;
|
|
|
|
// Auto-play the requested file
|
|
if (auto_play_idx >= 0) {
|
|
start_track(auto_play_idx);
|
|
}
|
|
|
|
render(pixels);
|
|
montauk::win_present(win_id);
|
|
|
|
uint64_t last_render = montauk::get_milliseconds();
|
|
|
|
while (true) {
|
|
montauk::abi::WinEvent ev;
|
|
bool redraw = false;
|
|
bool quit = false;
|
|
int r;
|
|
|
|
// Drain all pending events before rendering
|
|
while ((r = montauk::win_poll(win_id, &ev)) > 0) {
|
|
if (ev.type == 3) { quit = true; break; }
|
|
|
|
// Keyboard
|
|
if (ev.type == 0 && ev.key.pressed) {
|
|
if (handle_key(ev.key, quit))
|
|
redraw = true;
|
|
if (quit) break;
|
|
}
|
|
|
|
// Mouse
|
|
if (ev.type == 1) {
|
|
g.mouse_x = ev.mouse.x;
|
|
g.mouse_y = ev.mouse.y;
|
|
bool clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1);
|
|
bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1);
|
|
bool held = (ev.mouse.buttons & 1) != 0;
|
|
|
|
// List scrollbar drag/hover
|
|
bool sb_consume = false;
|
|
if (!g.show_visualizer && list_scrollbar_visible()) {
|
|
Rect sb_track, sb_thumb;
|
|
list_scrollbar_rects(g.win_w, &sb_track, &sb_thumb);
|
|
bool in_track = sb_track.contains(ev.mouse.x, ev.mouse.y);
|
|
|
|
if (g.sb_dragging && held) {
|
|
int list_h = sb_track.h;
|
|
int range = list_h - sb_thumb.h;
|
|
int max_scroll = g.filtered_count - visible_items();
|
|
if (range > 0 && max_scroll > 0) {
|
|
int new_ty = ev.mouse.y - g.sb_drag_offset;
|
|
int rel = new_ty - LIST_TOP;
|
|
if (rel < 0) rel = 0;
|
|
if (rel > range) rel = range;
|
|
int new_scroll = (rel * max_scroll + range / 2) / range;
|
|
if (new_scroll != g.scroll_y) {
|
|
g.scroll_y = new_scroll;
|
|
clamp_list_scroll();
|
|
redraw = true;
|
|
}
|
|
}
|
|
sb_consume = true;
|
|
} else if (g.sb_dragging && released) {
|
|
g.sb_dragging = false;
|
|
redraw = true;
|
|
sb_consume = true;
|
|
} else if (clicked && in_track) {
|
|
if (sb_thumb.contains(ev.mouse.x, ev.mouse.y)) {
|
|
g.sb_dragging = true;
|
|
g.sb_drag_offset = ev.mouse.y - sb_thumb.y;
|
|
} else {
|
|
int visible = visible_items();
|
|
int delta = visible > 1 ? visible - 1 : 1;
|
|
if (ev.mouse.y < sb_thumb.y) g.scroll_y -= delta;
|
|
else g.scroll_y += delta;
|
|
clamp_list_scroll();
|
|
}
|
|
redraw = true;
|
|
sb_consume = true;
|
|
}
|
|
if (in_track != g.sb_hovered) {
|
|
g.sb_hovered = in_track;
|
|
redraw = true;
|
|
}
|
|
} else if (g.sb_hovered) {
|
|
g.sb_hovered = false;
|
|
redraw = true;
|
|
}
|
|
|
|
if (clicked && !sb_consume) {
|
|
if (handle_click(ev.mouse.x, ev.mouse.y))
|
|
redraw = true;
|
|
}
|
|
|
|
if (released) {
|
|
g.dragging_progress = false;
|
|
}
|
|
|
|
// Progress bar drag
|
|
if (g.dragging_progress && (ev.mouse.buttons & 1)) {
|
|
int bar_x = 16, bar_w = g.win_w - 32;
|
|
int rel = ev.mouse.x - bar_x;
|
|
if (rel < 0) rel = 0;
|
|
if (rel > bar_w) rel = bar_w;
|
|
uint64_t target_samples = (uint64_t)rel * g.total_samples / bar_w;
|
|
seek_to_samples(target_samples);
|
|
redraw = true;
|
|
}
|
|
|
|
// Hover tracking for file list
|
|
int lb = list_bottom();
|
|
int hover_right = list_scrollbar_visible() ? (g.win_w - LIST_SB_W) : g.win_w;
|
|
if (!g.show_visualizer && ev.mouse.y >= LIST_TOP && ev.mouse.y < lb
|
|
&& ev.mouse.x >= 0 && ev.mouse.x < hover_right) {
|
|
int new_hover = filtered_raw_at((ev.mouse.y - LIST_TOP) / LIST_ITEM_H + g.scroll_y);
|
|
if (new_hover != g.hovered_item) {
|
|
g.hovered_item = new_hover;
|
|
redraw = true;
|
|
}
|
|
} else if (g.hovered_item != -1) {
|
|
g.hovered_item = -1;
|
|
redraw = true;
|
|
}
|
|
|
|
// Scroll
|
|
if (!g.show_visualizer && ev.mouse.scroll != 0) {
|
|
g.scroll_y -= ev.mouse.scroll * 2;
|
|
clamp_list_scroll();
|
|
redraw = true;
|
|
}
|
|
}
|
|
|
|
// Resize
|
|
if (ev.type == 2) {
|
|
g.win_w = ev.resize.w;
|
|
g.win_h = ev.resize.h;
|
|
pixels = (uint32_t*)(uintptr_t)montauk::win_resize(win_id, g.win_w, g.win_h);
|
|
ensure_selected_visible();
|
|
redraw = true;
|
|
}
|
|
}
|
|
|
|
if (quit || r < 0) break;
|
|
|
|
// Feed audio data to the device
|
|
feed_audio();
|
|
|
|
// Periodic redraw while playing (update time display)
|
|
uint64_t now = montauk::get_milliseconds();
|
|
if (g.play_state == PlayState::Playing && now - last_render >= 50) {
|
|
redraw = true;
|
|
}
|
|
|
|
if (redraw) {
|
|
music_visualizer::tick(g.visualizer);
|
|
render(pixels);
|
|
montauk::win_present(win_id);
|
|
last_render = now;
|
|
}
|
|
|
|
// Sleep less when playing to keep audio fed
|
|
if (g.play_state == PlayState::Playing)
|
|
montauk::sleep_ms(4);
|
|
else
|
|
montauk::sleep_ms(16);
|
|
}
|
|
|
|
stop_playback();
|
|
montauk::win_destroy(win_id);
|
|
montauk::exit(0);
|
|
}
|