summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-18 17:48:09 +0900
committerAdam Malczewski <[email protected]>2026-04-18 17:48:09 +0900
commit48e26ce606644de7b529f819d6aeffcdceef2ffc (patch)
tree8f53acbb51dac6289e6c528ce25ed2c73435cc8d
downloadstudy-player-48e26ce606644de7b529f819d6aeffcdceef2ffc.tar.gz
study-player-48e26ce606644de7b529f819d6aeffcdceef2ffc.zip
initial
-rw-r--r--.gitignore3
-rw-r--r--.rules/plan/phase1.md40
-rw-r--r--.rules/plan/phase2.md34
-rw-r--r--.rules/plan/phase3.md44
-rw-r--r--.rules/plan/plan.md138
-rw-r--r--Makefile54
-rw-r--r--README.md49
-rwxr-xr-xbin/build7
-rwxr-xr-xbin/clean7
-rw-r--r--src/main.c147
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;
+}