diff options
| author | Adam Malczewski <[email protected]> | 2026-04-18 17:48:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-18 17:48:09 +0900 |
| commit | 48e26ce606644de7b529f819d6aeffcdceef2ffc (patch) | |
| tree | 8f53acbb51dac6289e6c528ce25ed2c73435cc8d | |
| download | study-player-48e26ce606644de7b529f819d6aeffcdceef2ffc.tar.gz study-player-48e26ce606644de7b529f819d6aeffcdceef2ffc.zip | |
initial
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .rules/plan/phase1.md | 40 | ||||
| -rw-r--r-- | .rules/plan/phase2.md | 34 | ||||
| -rw-r--r-- | .rules/plan/phase3.md | 44 | ||||
| -rw-r--r-- | .rules/plan/plan.md | 138 | ||||
| -rw-r--r-- | Makefile | 54 | ||||
| -rw-r--r-- | README.md | 49 | ||||
| -rwxr-xr-x | bin/build | 7 | ||||
| -rwxr-xr-x | bin/clean | 7 | ||||
| -rw-r--r-- | src/main.c | 147 |
10 files changed, 523 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02bd1f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +deps/ +resources/ diff --git a/.rules/plan/phase1.md b/.rules/plan/phase1.md new file mode 100644 index 0000000..d1a1a55 --- /dev/null +++ b/.rules/plan/phase1.md @@ -0,0 +1,40 @@ +# Phase 1 — Audio Playback & Controls + +Core audio functionality. After this phase the app is usable as a keyboard-driven player with no visual progress display. + +--- + +## 1.1 Drag & drop file loading + +- Use `IsFileDropped()` / `LoadDroppedFiles()` / `UnloadDroppedFiles()`. +- Validate file extension is `.mp3` (case-insensitive). +- Unload previous `MusicStream` if one is already loaded. +- `LoadMusicStream()`, `PlayMusicStream()`, set `state.loaded = true`, `state.playing = true`. +- Store duration via `GetMusicTimeLength()`. +- Extract basename from path for `state.filename`. + +## 1.2 Play/pause with rewind + +- `Space` toggles play/pause. +- On pause: capture current position via `GetMusicTimePlayed()`, call `PauseMusicStream()`, then `SeekMusicStream()` back 1 second (clamped to 0). +- On resume: `ResumeMusicStream()`. + +## 1.3 Arrow key seeking + +- `Left Arrow`: seek backward 5 seconds (clamped to 0). +- `Right Arrow`: seek forward 5 seconds (clamped to duration). +- Works in both playing and paused states. + +## 1.4 Music stream update + +- Call `UpdateMusicStream()` every frame when `state.loaded` is true. +- This is required for Raylib's streaming audio to function. + +--- + +## Acceptance criteria + +- Can drag an MP3 onto the window and hear it play immediately. +- Space pauses (with 1s rewind) and resumes. +- Arrow keys seek ±5s with no audible delay. +- Dropping a new file replaces the current one cleanly (no audio glitches). diff --git a/.rules/plan/phase2.md b/.rules/plan/phase2.md new file mode 100644 index 0000000..eeb0eff --- /dev/null +++ b/.rules/plan/phase2.md @@ -0,0 +1,34 @@ +# Phase 2 — Progress Bar & Seeking UI + +Visual progress display and mouse-based seeking. + +--- + +## 2.1 Progress bar rendering + +- Horizontal bar centered at ~Y=360, width = 80% of window (1024px), height = 20px. +- Background: dark gray rectangle via `DrawRectangleRec()`. +- Fill: accent color rectangle, width proportional to `currentTime / duration`. +- Only drawn when `state.loaded` is true. + +## 2.2 Time labels + +- Left of bar: current time formatted as `MM:SS` or `HH:MM:SS` (if ≥ 3600s). +- Right of bar: total duration in the same format. +- Helper function: `void format_time(float seconds, char *buf, int bufsize)`. + +## 2.3 Click-to-seek + +- On `IsMouseButtonPressed(MOUSE_BUTTON_LEFT)`, check if click is within progress bar bounding box. +- Compute target time: `(mouseX - barX) / barWidth * duration`. +- Call `SeekMusicStream()` to that position. +- Works in both playing and paused states. + +--- + +## Acceptance criteria + +- Progress bar visually tracks playback position in real time. +- Time labels update every frame and format correctly (MM:SS vs HH:MM:SS). +- Clicking anywhere on the bar seeks to the corresponding position instantly. +- Clicking outside the bar does nothing. diff --git a/.rules/plan/phase3.md b/.rules/plan/phase3.md new file mode 100644 index 0000000..ead88b4 --- /dev/null +++ b/.rules/plan/phase3.md @@ -0,0 +1,44 @@ +# Phase 3 — Polish & Status Display + +Final UI elements and visual polish. + +--- + +## 3.1 Title text + +- Draw "Study Player" centered near top of window (large font size, ~30px). + +## 3.2 Filename display + +- Below title: show loaded filename, or "Drag an MP3 file here" when nothing is loaded. +- Centered horizontally. + +## 3.3 Playback status + +- Below progress bar: display "PLAYING" or "PAUSED" centered. +- Show nothing when no file is loaded. + +## 3.4 Help text + +- Bottom area of window: "Space: play/pause ←/→: seek 5s Click bar: seek". +- Smaller font, muted color. + +## 3.5 Window title update + +- Call `SetWindowTitle()` to include the filename when a file is loaded (e.g., "Study Player - chapter1.mp3"). +- Reset to "Study Player" if relevant. + +## 3.6 Visual refinements + +- Consistent font sizes and vertical spacing across all text elements. +- Color scheme: dark background (#1a1a2e or similar), light text (#eaeaea), accent color for progress fill (#e94560 or similar). +- Ensure all text is readable and well-positioned at 1280×720. + +--- + +## Acceptance criteria + +- All text elements are visible and properly centered. +- Status reflects actual playback state and updates immediately on play/pause. +- Window title bar shows the current filename. +- The app looks clean and intentional — no overlapping elements, no clipping. diff --git a/.rules/plan/plan.md b/.rules/plan/plan.md new file mode 100644 index 0000000..d8a94c0 --- /dev/null +++ b/.rules/plan/plan.md @@ -0,0 +1,138 @@ +# Study Player — Implementation Plan + +## Overview + +A single-file C application using Raylib that plays MP3 audiobooks with minimal UI focused on keyboard-driven study workflow. + +--- + +## Build + +- **Build system:** Single `Makefile` at project root with cross-platform support. +- **Linux:** Compile with gcc, link against raylib, `-lm -lpthread -ldl -lrt -lX11`. +- **Windows:** Cross-compile with `x86_64-w64-mingw32-gcc`, link against raylib, `-lgdi32 -lwinmm`. Alternatively native MSVC or MinGW on Windows. +- **Makefile targets:** `make` (Linux default), `make PLATFORM=WINDOWS` (cross-compile for Windows). +- **Dependencies:** `deps/raylib` (already present), `deps/raygui` (already present — optional). +- **Source:** `src/main.c` (single file). +- **Output:** `build/study-player` (Linux), `build/study-player.exe` (Windows). +- **Compile raylib as static library** first (`deps/raylib/src/`), then link. +- **bin/build:** Shell script wrapping `make`. +- **bin/run:** Shell script wrapping `make && ./build/study-player`. + +--- + +## Application State + +```c +typedef struct { + Music music; // Raylib Music stream (MP3) + bool loaded; // Whether a file is loaded + bool playing; // Whether audio is currently playing + char filepath[512]; // Path of loaded file + char filename[256]; // Display name (basename) + float duration; // Total length in seconds +} AppState; +``` + +No persistence. All state is in-memory only. + +--- + +## Window + +- **Size:** 1920×1080, not resizable. +- **Title:** "Study Player" (update to include filename when loaded). +- **Target FPS:** 60. +- **Background:** Dark solid color. + +--- + +## Audio Approach — Instant Seek + +Raylib's `Music` type is a streaming audio type. Key functions: + +- `LoadMusicStream(path)` — load MP3. +- `PlayMusicStream(music)` / `PauseMusicStream(music)` / `ResumeMusicStream(music)`. +- `SeekMusicStream(music, positionInSeconds)` — instant seek. +- `GetMusicTimePlayed(music)` — current position. +- `GetMusicTimeLength(music)` — total duration. +- `UpdateMusicStream(music)` — **must be called every frame** to feed audio buffer. + +These provide instant play/pause/seek with no delay. + +--- + +## UI Layout (1920×1080) + +``` ++--------------------------------------------------+ +| | +| "Study Player" (title) | +| | +| [filename or "Drop an MP3 file"] | +| | +| | +| 03:42 [=======>-----------------] 58:31 | +| ^ progress bar (clickable) | +| | +| PLAYING / PAUSED / (empty) | +| | +| Space: play/pause ←→: seek 5s | +| | ++--------------------------------------------------+ +``` + +--- + +## Scaffolding (before phases) + +Set up before any feature work: + +1. **Makefile** — cross-platform (Linux + Windows cross-compile). Compile raylib as static lib, compile+link `src/main.c`. +2. **bin/build, bin/run** — convenience shell scripts. +3. **src/main.c skeleton** — `InitWindow`, `InitAudioDevice`, empty main loop with `ClearBackground`, `CloseWindow`. Confirms build pipeline works. +4. **build/ in .gitignore**. + +--- + +## Phases + +Implementation is split into three phases, each in its own file: + +- **[Phase 1 — Audio Playback & Controls](phase1.md):** Drag-and-drop loading, play/pause with 1s rewind, arrow key seeking. +- **[Phase 2 — Progress Bar & Seeking UI](phase2.md):** Progress bar rendering, time labels, click-to-seek. +- **[Phase 3 — Polish & Status Display](phase3.md):** Title, filename, status text, help text, window title, visual refinements. + +--- + +## File Structure + +``` +study-player/ +├── .rules/plan/ +│ ├── plan.md # This file (overview + shared context) +│ ├── phase1.md # Audio playback & controls +│ ├── phase2.md # Progress bar & seeking UI +│ └── phase3.md # Polish & status display +├── deps/ +│ ├── raylib/ # Already present +│ └── raygui/ # Already present (optional use) +├── src/ +│ └── main.c # All application code +├── Makefile # Build raylib + application (Linux & Windows) +├── bin/ +│ ├── build # make +│ └── run # make && ./build/study-player +└── build/ # Build artifacts (gitignored) +``` + +--- + +## Constraints + +- MP3 only. +- No playlist / queue — one file at a time. +- No persistence — position lost on close. +- No volume control (system volume is sufficient). +- Window fixed at 1920×1080. +- Must build for Windows (cross-compile from Linux with MinGW, or native MinGW on Windows). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b30a2d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# Study Player Makefile +# Builds raylib as a static library, then compiles the application for Windows. +# Usage: +# make - Build for Windows (cross-compile with MinGW) +# make clean - Remove build artifacts + +# Directories +SRC_DIR := src +BUILD_DIR := build +RAYLIB_SRC := deps/raylib/src +RAYLIB_LIB := $(BUILD_DIR)/libraylib.a + +# Source files +APP_SRC := $(SRC_DIR)/main.c + +CC := x86_64-w64-mingw32-gcc +APP_OUT := $(BUILD_DIR)/study-player.exe +LDFLAGS := -lgdi32 -lwinmm -lcomdlg32 -lole32 +DEFINES := -DPLATFORM_DESKTOP -D_GLFW_WIN32 + +CFLAGS_COMMON := -std=c99 -O2 -I$(RAYLIB_SRC) -I$(RAYLIB_SRC)/external/glfw/include +CFLAGS := $(CFLAGS_COMMON) -Wall -Wextra +CFLAGS_RAYLIB := $(CFLAGS_COMMON) -w + +# Raylib source files +RAYLIB_SRCS := $(RAYLIB_SRC)/rcore.c $(RAYLIB_SRC)/rshapes.c $(RAYLIB_SRC)/rtextures.c \ + $(RAYLIB_SRC)/rtext.c $(RAYLIB_SRC)/rmodels.c $(RAYLIB_SRC)/raudio.c \ + $(RAYLIB_SRC)/rglfw.c +RAYLIB_OBJS := $(patsubst $(RAYLIB_SRC)/%.c,$(BUILD_DIR)/raylib/%.o,$(RAYLIB_SRCS)) + +.PHONY: all clean + +all: $(APP_OUT) + +# Build application +$(APP_OUT): $(APP_SRC) $(RAYLIB_LIB) | $(BUILD_DIR) + $(CC) $(CFLAGS) $(DEFINES) -o $@ $< -L$(BUILD_DIR) -lraylib $(LDFLAGS) + +# Archive raylib objects into static library +$(RAYLIB_LIB): $(RAYLIB_OBJS) | $(BUILD_DIR) + ar rcs $@ $^ + +# Compile raylib source files +$(BUILD_DIR)/raylib/%.o: $(RAYLIB_SRC)/%.c | $(BUILD_DIR)/raylib + $(CC) $(CFLAGS_RAYLIB) $(DEFINES) -c -o $@ $< + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +$(BUILD_DIR)/raylib: + mkdir -p $(BUILD_DIR)/raylib + +clean: + rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8335c1 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Study Player + +A keyboard-driven MP3 player built with [raylib](https://github.com/raysan5/raylib), cross-compiled for Windows from Linux using MinGW. + +### Dependencies + +Clone the following repositories into the `deps/` directory: + +```bash +mkdir -p deps +git clone https://github.com/raysan5/raylib.git deps/raylib +git clone https://github.com/raysan5/raygui.git deps/raygui +``` + +You also need the MinGW cross-compiler installed: + +```bash +# Debian/Ubuntu +sudo apt install mingw-w64 +``` + +### Building + +```bash +make +``` + +The output binary is `build/study-player.exe`. + +### Usage + +Run the `.exe` on Windows (or under Wine). The controls are: + +| Key | Action | +|---|---| +| **Drag & drop** | Drop an `.mp3` file onto the window to load and play it | +| **Space** | Pause (rewinds 1 second) / Resume | +| **Left Arrow** | Seek backward 5 seconds | +| **Right Arrow** | Seek forward 5 seconds | + +### Project Structure + +``` +src/main.c Application source +deps/raylib/ raylib (cloned separately) +deps/raygui/ raygui (cloned separately) +build/ Build output (gitignored) +resources/ Runtime resources (gitignored) +``` diff --git a/bin/build b/bin/build new file mode 100755 index 0000000..8bd09f9 --- /dev/null +++ b/bin/build @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +make -j$(nproc) "$@" diff --git a/bin/clean b/bin/clean new file mode 100755 index 0000000..91ee73b --- /dev/null +++ b/bin/clean @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +make clean diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..0b9c795 --- /dev/null +++ b/src/main.c @@ -0,0 +1,147 @@ +#include "raylib.h" +#include <string.h> +#include <ctype.h> + +typedef struct { + Music music; + bool loaded; + bool playing; + float duration; + char filename[256]; +} PlayerState; + +static int strcasecmp_ext(const char *a, const char *b) +{ + while (*a && *b) { + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return 1; + a++; b++; + } + return *a != *b; +} + +static const char *basename_from_path(const char *path) +{ + const char *last = path; + for (const char *p = path; *p; p++) { + if (*p == '/' || *p == '\\') last = p + 1; + } + return last; +} + +static void seek_to(PlayerState *s, float target) +{ + if (target < 0.0f) target = 0.0f; + if (target > s->duration) target = s->duration; + SeekMusicStream(s->music, target); +} + +int main(void) +{ + InitWindow(1920, 1080, "Study Player"); + InitAudioDevice(); + SetTargetFPS(60); + + PlayerState state = { 0 }; + + while (!WindowShouldClose()) + { + /* --- 1.1 Drag & drop file loading --- */ + if (IsFileDropped()) + { + FilePathList files = LoadDroppedFiles(); + if (files.count > 0) + { + const char *path = files.paths[0]; + const char *ext = GetFileExtension(path); + + if (ext != NULL && strcasecmp_ext(ext, ".mp3") == 0) + { + /* Unload previous */ + if (state.loaded) + { + StopMusicStream(state.music); + UnloadMusicStream(state.music); + state.loaded = false; + state.playing = false; + } + + state.music = LoadMusicStream(path); + state.duration = GetMusicTimeLength(state.music); + state.loaded = true; + state.playing = true; + + const char *base = basename_from_path(path); + strncpy(state.filename, base, sizeof(state.filename) - 1); + state.filename[sizeof(state.filename) - 1] = '\0'; + + PlayMusicStream(state.music); + } + } + UnloadDroppedFiles(files); + } + + /* --- 1.4 Music stream update --- */ + if (state.loaded) + { + UpdateMusicStream(state.music); + } + + /* --- 1.2 Play/pause with rewind --- */ + if (state.loaded && IsKeyPressed(KEY_SPACE)) + { + if (state.playing) + { + float pos = GetMusicTimePlayed(state.music); + PauseMusicStream(state.music); + float rewind = pos - 1.0f; + if (rewind < 0.0f) rewind = 0.0f; + SeekMusicStream(state.music, rewind); + state.playing = false; + } + else + { + ResumeMusicStream(state.music); + state.playing = true; + } + } + + /* --- 1.3 Arrow key seeking --- */ + if (state.loaded && IsKeyPressed(KEY_LEFT)) + { + float pos = GetMusicTimePlayed(state.music); + seek_to(&state, pos - 5.0f); + } + if (state.loaded && IsKeyPressed(KEY_RIGHT)) + { + float pos = GetMusicTimePlayed(state.music); + seek_to(&state, pos + 5.0f); + } + + /* --- Drawing --- */ + BeginDrawing(); + ClearBackground((Color){ 26, 26, 46, 255 }); + + if (state.loaded) + { +DrawText(state.filename, 40, 40, 40, RAYWHITE); +DrawText(state.playing ? "Playing" : "Paused", 40, 90, 30, GRAY); + } + else + { +DrawText("Drop an MP3 file here to play", 540, 340, 40, RAYWHITE); + } + + EndDrawing(); + } + + if (state.loaded) + { + StopMusicStream(state.music); + UnloadMusicStream(state.music); + } + + CloseAudioDevice(); + CloseWindow(); + + return 0; +} |
