Add volume control and d-pad/keyboard navigation
Replace mouse input with cursor-based navigation (arrow keys / d-pad + Enter / A button) and add app-level volume control with a persistent vertical slider, enabling use on a handheld gaming device without mouse or system mixer access. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f65414947
commit
3ba7b31148
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,5 +3,6 @@
|
|||||||
*.mp3
|
*.mp3
|
||||||
*.m4a
|
*.m4a
|
||||||
positions.txt
|
positions.txt
|
||||||
|
volume.txt
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,14 @@ This document specifies the functional requirements for an SDL2 based media play
|
|||||||
|
|
||||||
## 5. Changelog
|
## 5. Changelog
|
||||||
|
|
||||||
|
### 2026-02-10 — Volume control and d-pad/keyboard navigation
|
||||||
|
|
||||||
|
- **Cursor-based navigation**: Replaced mouse input with a focus-highlight model. Arrow keys (Left/Right) and d-pad move a visible blue highlight between UI elements; Enter/A button activates the focused button. Designed for use on a handheld gaming device with no mouse.
|
||||||
|
- **App-level volume control**: Float samples are scaled by a volume factor (0–100%) before being queued to the audio device. Volume slider rendered as a vertical bar to the left of the transport buttons, using the same gray palette as the progress bar.
|
||||||
|
- **Volume persistence**: Volume level saved to `volume.txt` in the audio directory. Defaults to 50% on first run. Loaded on startup, saved on quit and on every adjustment.
|
||||||
|
- **SDL_GameController support**: Uses `SDL_INIT_GAMECONTROLLER` and the `SDL_GameController` API to normalize d-pad input across hardware. Hot-plug support via `SDL_CONTROLLERDEVICEADDED/REMOVED`. Keyboard (arrow keys + Enter) works identically for desktop testing.
|
||||||
|
- **Removed mouse input**: `SDL_MOUSEBUTTONDOWN` handler removed entirely; all interaction is now via keyboard or gamepad.
|
||||||
|
|
||||||
### 2026-02-10 — Full implementation of audio player features
|
### 2026-02-10 — Full implementation of audio player features
|
||||||
|
|
||||||
- **Streaming decoder**: Replaced fire-and-forget `decode_audio()` with a persistent `Decoder` struct that streams audio on demand via `decoder_pump()`. Uses `libswresample` (`swr_alloc_set_opts2`) to convert from the decoder's native format (e.g. planar float) to interleaved float stereo 48kHz. Fixes the sped-up/distorted audio bug and eliminates the multi-GB memory spike for long files.
|
- **Streaming decoder**: Replaced fire-and-forget `decode_audio()` with a persistent `Decoder` struct that streams audio on demand via `decoder_pump()`. Uses `libswresample` (`swr_alloc_set_opts2`) to convert from the decoder's native format (e.g. planar float) to interleaved float stereo 48kHz. Fixes the sped-up/distorted audio bug and eliminates the multi-GB memory spike for long files.
|
||||||
|
|||||||
218
src/sdlamp2.c
218
src/sdlamp2.c
@ -52,6 +52,20 @@ static char audio_files[MAX_FILES][256];
|
|||||||
static int num_audio_files = 0;
|
static int num_audio_files = 0;
|
||||||
static int current_file_index = 0;
|
static int current_file_index = 0;
|
||||||
|
|
||||||
|
/* --- Focus / navigation --- */
|
||||||
|
|
||||||
|
#define FOCUS_VOLUME 0
|
||||||
|
#define FOCUS_REWIND 1
|
||||||
|
#define FOCUS_STOP 2
|
||||||
|
#define FOCUS_PLAY 3
|
||||||
|
#define FOCUS_FF 4
|
||||||
|
#define FOCUS_NEXT 5
|
||||||
|
#define FOCUS_COUNT 6
|
||||||
|
|
||||||
|
static float volume = 0.5f;
|
||||||
|
static int focus_index = FOCUS_PLAY;
|
||||||
|
static SDL_GameController* controller = NULL;
|
||||||
|
|
||||||
/* --- Utility --- */
|
/* --- Utility --- */
|
||||||
|
|
||||||
static void panic_and_abort(const char* title, const char* text) {
|
static void panic_and_abort(const char* title, const char* text) {
|
||||||
@ -138,6 +152,38 @@ static double load_position(const char* filename) {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Volume persistence --- */
|
||||||
|
|
||||||
|
static void save_volume(void) {
|
||||||
|
char path[1024];
|
||||||
|
snprintf(path, sizeof(path), "%s/volume.txt", audio_dir);
|
||||||
|
FILE* f = fopen(path, "w");
|
||||||
|
if (f) {
|
||||||
|
fprintf(f, "%.2f\n", volume);
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static float load_volume(void) {
|
||||||
|
char path[1024];
|
||||||
|
snprintf(path, sizeof(path), "%s/volume.txt", audio_dir);
|
||||||
|
FILE* f = fopen(path, "r");
|
||||||
|
if (!f) return 0.5f;
|
||||||
|
float v = 0.5f;
|
||||||
|
if (fscanf(f, "%f", &v) != 1) v = 0.5f;
|
||||||
|
fclose(f);
|
||||||
|
if (v < 0.0f) v = 0.0f;
|
||||||
|
if (v > 1.0f) v = 1.0f;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void adjust_volume(float delta) {
|
||||||
|
volume += delta;
|
||||||
|
if (volume < 0.0f) volume = 0.0f;
|
||||||
|
if (volume > 1.0f) volume = 1.0f;
|
||||||
|
save_volume();
|
||||||
|
}
|
||||||
|
|
||||||
/* --- File scanning --- */
|
/* --- File scanning --- */
|
||||||
|
|
||||||
static int has_audio_extension(const char* name) {
|
static int has_audio_extension(const char* name) {
|
||||||
@ -443,6 +489,47 @@ static void switch_file(int index) {
|
|||||||
SDL_SetWindowTitle(window, "SDLamp2 - No playable files");
|
SDL_SetWindowTitle(window, "SDLamp2 - No playable files");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Navigation --- */
|
||||||
|
|
||||||
|
static void activate_focused_button(void) {
|
||||||
|
switch (focus_index) {
|
||||||
|
case FOCUS_REWIND: {
|
||||||
|
double pos = get_current_seconds() - SEEK_SECONDS;
|
||||||
|
decoder_seek(pos < 0.0 ? 0.0 : pos);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FOCUS_STOP:
|
||||||
|
if (!paused) {
|
||||||
|
paused = SDL_TRUE;
|
||||||
|
SDL_PauseAudioDevice(audio_device, 1);
|
||||||
|
if (current_file[0]) {
|
||||||
|
save_position(current_file, get_current_seconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case FOCUS_PLAY:
|
||||||
|
if (paused && num_audio_files > 0) {
|
||||||
|
paused = SDL_FALSE;
|
||||||
|
SDL_PauseAudioDevice(audio_device, 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case FOCUS_FF: {
|
||||||
|
double pos = get_current_seconds() + SEEK_SECONDS;
|
||||||
|
double dur = get_duration_seconds();
|
||||||
|
decoder_seek((dur > 0.0 && pos > dur) ? dur : pos);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FOCUS_NEXT:
|
||||||
|
if (num_audio_files > 0) {
|
||||||
|
int next = (current_file_index + 1) % num_audio_files;
|
||||||
|
switch_file(next);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Main --- */
|
/* --- Main --- */
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
@ -451,6 +538,9 @@ int main(int argc, char** argv) {
|
|||||||
audio_dir[sizeof(audio_dir) - 1] = '\0';
|
audio_dir[sizeof(audio_dir) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Volume slider (left of buttons) */
|
||||||
|
const SDL_Rect volume_bg = {25, 390, 30, 80};
|
||||||
|
|
||||||
/* Button positions (bottom of window, centered) */
|
/* Button positions (bottom of window, centered) */
|
||||||
const SDL_Rect rewind_btn = {80, 390, 80, 80};
|
const SDL_Rect rewind_btn = {80, 390, 80, 80};
|
||||||
const SDL_Rect stop_btn = {180, 390, 80, 80};
|
const SDL_Rect stop_btn = {180, 390, 80, 80};
|
||||||
@ -458,6 +548,10 @@ int main(int argc, char** argv) {
|
|||||||
const SDL_Rect ff_btn = {380, 390, 80, 80};
|
const SDL_Rect ff_btn = {380, 390, 80, 80};
|
||||||
const SDL_Rect next_btn = {480, 390, 80, 80};
|
const SDL_Rect next_btn = {480, 390, 80, 80};
|
||||||
|
|
||||||
|
/* Array of focusable rects indexed by FOCUS_* constants */
|
||||||
|
const SDL_Rect* focus_rects[FOCUS_COUNT] = {&volume_bg, &rewind_btn, &stop_btn,
|
||||||
|
&play_btn, &ff_btn, &next_btn};
|
||||||
|
|
||||||
/* Sprite sheet source rects */
|
/* Sprite sheet source rects */
|
||||||
const SDL_Rect rewind_sprite = {0, 0, 200, 200};
|
const SDL_Rect rewind_sprite = {0, 0, 200, 200};
|
||||||
const SDL_Rect play_sprite = {220, 0, 200, 200};
|
const SDL_Rect play_sprite = {220, 0, 200, 200};
|
||||||
@ -468,7 +562,7 @@ int main(int argc, char** argv) {
|
|||||||
/* Progress bar area */
|
/* Progress bar area */
|
||||||
const SDL_Rect progress_bg = {20, 360, 600, 15};
|
const SDL_Rect progress_bg = {20, 360, 600, 15};
|
||||||
|
|
||||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) != 0) {
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) {
|
||||||
panic_and_abort("SDL_Init failed!", SDL_GetError());
|
panic_and_abort("SDL_Init failed!", SDL_GetError());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +590,17 @@ int main(int argc, char** argv) {
|
|||||||
panic_and_abort("Could not open audio device!", SDL_GetError());
|
panic_and_abort("Could not open audio device!", SDL_GetError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Open first available game controller */
|
||||||
|
for (int i = 0; i < SDL_NumJoysticks(); i++) {
|
||||||
|
if (SDL_IsGameController(i)) {
|
||||||
|
controller = SDL_GameControllerOpen(i);
|
||||||
|
if (controller) {
|
||||||
|
printf("Controller: %s\n", SDL_GameControllerName(controller));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SDL_Surface* controls_surface = IMG_Load("controls.png");
|
SDL_Surface* controls_surface = IMG_Load("controls.png");
|
||||||
if (!controls_surface) {
|
if (!controls_surface) {
|
||||||
panic_and_abort("Could not load controls asset!", SDL_GetError());
|
panic_and_abort("Could not load controls asset!", SDL_GetError());
|
||||||
@ -509,6 +614,7 @@ int main(int argc, char** argv) {
|
|||||||
|
|
||||||
/* Scan directory and open first file */
|
/* Scan directory and open first file */
|
||||||
scan_audio_files(audio_dir);
|
scan_audio_files(audio_dir);
|
||||||
|
volume = load_volume();
|
||||||
if (num_audio_files > 0) {
|
if (num_audio_files > 0) {
|
||||||
switch_file(0);
|
switch_file(0);
|
||||||
} else {
|
} else {
|
||||||
@ -526,37 +632,65 @@ int main(int argc, char** argv) {
|
|||||||
running = SDL_FALSE;
|
running = SDL_FALSE;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SDL_MOUSEBUTTONDOWN: {
|
case SDL_KEYDOWN:
|
||||||
const SDL_Point pt = {e.button.x, e.button.y};
|
switch (e.key.keysym.sym) {
|
||||||
|
case SDLK_LEFT:
|
||||||
|
focus_index = (focus_index - 1 + FOCUS_COUNT) % FOCUS_COUNT;
|
||||||
|
break;
|
||||||
|
case SDLK_RIGHT:
|
||||||
|
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||||
|
break;
|
||||||
|
case SDLK_UP:
|
||||||
|
if (focus_index == FOCUS_VOLUME) adjust_volume(0.05f);
|
||||||
|
break;
|
||||||
|
case SDLK_DOWN:
|
||||||
|
if (focus_index == FOCUS_VOLUME) adjust_volume(-0.05f);
|
||||||
|
break;
|
||||||
|
case SDLK_RETURN:
|
||||||
|
case SDLK_KP_ENTER:
|
||||||
|
activate_focused_button();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
if (SDL_PointInRect(&pt, &rewind_btn)) {
|
case SDL_CONTROLLERBUTTONDOWN:
|
||||||
double pos = get_current_seconds() - SEEK_SECONDS;
|
switch (e.cbutton.button) {
|
||||||
decoder_seek(pos < 0.0 ? 0.0 : pos);
|
case SDL_CONTROLLER_BUTTON_DPAD_LEFT:
|
||||||
} else if (SDL_PointInRect(&pt, &stop_btn)) {
|
focus_index = (focus_index - 1 + FOCUS_COUNT) % FOCUS_COUNT;
|
||||||
if (!paused) {
|
break;
|
||||||
paused = SDL_TRUE;
|
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT:
|
||||||
SDL_PauseAudioDevice(audio_device, 1);
|
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||||
if (current_file[0]) {
|
break;
|
||||||
save_position(current_file, get_current_seconds());
|
case SDL_CONTROLLER_BUTTON_DPAD_UP:
|
||||||
|
if (focus_index == FOCUS_VOLUME) adjust_volume(0.05f);
|
||||||
|
break;
|
||||||
|
case SDL_CONTROLLER_BUTTON_DPAD_DOWN:
|
||||||
|
if (focus_index == FOCUS_VOLUME) adjust_volume(-0.05f);
|
||||||
|
break;
|
||||||
|
case SDL_CONTROLLER_BUTTON_A:
|
||||||
|
activate_focused_button();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
} else if (SDL_PointInRect(&pt, &play_btn)) {
|
|
||||||
if (paused && num_audio_files > 0) {
|
case SDL_CONTROLLERDEVICEADDED:
|
||||||
paused = SDL_FALSE;
|
if (!controller && SDL_IsGameController(e.cdevice.which)) {
|
||||||
SDL_PauseAudioDevice(audio_device, 0);
|
controller = SDL_GameControllerOpen(e.cdevice.which);
|
||||||
}
|
if (controller) {
|
||||||
} else if (SDL_PointInRect(&pt, &ff_btn)) {
|
printf("Controller added: %s\n", SDL_GameControllerName(controller));
|
||||||
double pos = get_current_seconds() + SEEK_SECONDS;
|
|
||||||
double dur = get_duration_seconds();
|
|
||||||
decoder_seek((dur > 0.0 && pos > dur) ? dur : pos);
|
|
||||||
} else if (SDL_PointInRect(&pt, &next_btn)) {
|
|
||||||
if (num_audio_files > 0) {
|
|
||||||
int next = (current_file_index + 1) % num_audio_files;
|
|
||||||
switch_file(next);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SDL_CONTROLLERDEVICEREMOVED:
|
||||||
|
if (controller && e.cdevice.which ==
|
||||||
|
SDL_JoystickInstanceID(
|
||||||
|
SDL_GameControllerGetJoystick(controller))) {
|
||||||
|
SDL_GameControllerClose(controller);
|
||||||
|
controller = NULL;
|
||||||
|
printf("Controller removed\n");
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,6 +710,11 @@ int main(int argc, char** argv) {
|
|||||||
static Uint8 transfer_buf[32768];
|
static Uint8 transfer_buf[32768];
|
||||||
int got = SDL_AudioStreamGet(stream, transfer_buf, to_get);
|
int got = SDL_AudioStreamGet(stream, transfer_buf, to_get);
|
||||||
if (got > 0) {
|
if (got > 0) {
|
||||||
|
float* samples = (float*)transfer_buf;
|
||||||
|
int num_samples = got / (int)sizeof(float);
|
||||||
|
for (int i = 0; i < num_samples; i++) {
|
||||||
|
samples[i] *= volume;
|
||||||
|
}
|
||||||
SDL_QueueAudio(audio_device, transfer_buf, got);
|
SDL_QueueAudio(audio_device, transfer_buf, got);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -621,6 +760,16 @@ int main(int argc, char** argv) {
|
|||||||
SDL_RenderFillRect(renderer, &fill);
|
SDL_RenderFillRect(renderer, &fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Volume slider */
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0xC0, 0xC0, 0xC0, 0xFF);
|
||||||
|
SDL_RenderFillRect(renderer, &volume_bg);
|
||||||
|
{
|
||||||
|
int fill_h = (int)(volume_bg.h * volume);
|
||||||
|
SDL_Rect vol_fill = {volume_bg.x, volume_bg.y + volume_bg.h - fill_h, volume_bg.w, fill_h};
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0x50, 0x50, 0x50, 0xFF);
|
||||||
|
SDL_RenderFillRect(renderer, &vol_fill);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
SDL_RenderCopy(renderer, controls_texture, &rewind_sprite, &rewind_btn);
|
SDL_RenderCopy(renderer, controls_texture, &rewind_sprite, &rewind_btn);
|
||||||
SDL_RenderCopy(renderer, controls_texture, &stop_sprite, &stop_btn);
|
SDL_RenderCopy(renderer, controls_texture, &stop_sprite, &stop_btn);
|
||||||
@ -628,6 +777,21 @@ int main(int argc, char** argv) {
|
|||||||
SDL_RenderCopy(renderer, controls_texture, &ff_sprite, &ff_btn);
|
SDL_RenderCopy(renderer, controls_texture, &ff_sprite, &ff_btn);
|
||||||
SDL_RenderCopy(renderer, controls_texture, &next_sprite, &next_btn);
|
SDL_RenderCopy(renderer, controls_texture, &next_sprite, &next_btn);
|
||||||
|
|
||||||
|
/* Focus highlight — 3px blue border around focused element */
|
||||||
|
{
|
||||||
|
const SDL_Rect r = *focus_rects[focus_index];
|
||||||
|
const int t = 3;
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0x00, 0x80, 0xFF, 0xFF);
|
||||||
|
SDL_Rect top = {r.x - t, r.y - t, r.w + 2 * t, t};
|
||||||
|
SDL_Rect bot = {r.x - t, r.y + r.h, r.w + 2 * t, t};
|
||||||
|
SDL_Rect lft = {r.x - t, r.y, t, r.h};
|
||||||
|
SDL_Rect rgt = {r.x + r.w, r.y, t, r.h};
|
||||||
|
SDL_RenderFillRect(renderer, &top);
|
||||||
|
SDL_RenderFillRect(renderer, &bot);
|
||||||
|
SDL_RenderFillRect(renderer, &lft);
|
||||||
|
SDL_RenderFillRect(renderer, &rgt);
|
||||||
|
}
|
||||||
|
|
||||||
SDL_RenderPresent(renderer);
|
SDL_RenderPresent(renderer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,9 +799,11 @@ int main(int argc, char** argv) {
|
|||||||
if (current_file[0] && decoder.format_ctx) {
|
if (current_file[0] && decoder.format_ctx) {
|
||||||
save_position(current_file, get_current_seconds());
|
save_position(current_file, get_current_seconds());
|
||||||
}
|
}
|
||||||
|
save_volume();
|
||||||
decoder_close();
|
decoder_close();
|
||||||
SDL_FreeAudioStream(stream);
|
SDL_FreeAudioStream(stream);
|
||||||
SDL_DestroyTexture(controls_texture);
|
SDL_DestroyTexture(controls_texture);
|
||||||
|
if (controller) SDL_GameControllerClose(controller);
|
||||||
SDL_CloseAudioDevice(audio_device);
|
SDL_CloseAudioDevice(audio_device);
|
||||||
SDL_DestroyRenderer(renderer);
|
SDL_DestroyRenderer(renderer);
|
||||||
SDL_DestroyWindow(window);
|
SDL_DestroyWindow(window);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user