summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 22:23:59 +0900
committerAdam Malczewski <[email protected]>2026-06-27 22:23:59 +0900
commit6bf8907cd69383e8bb0a77b741d314bddf8008e6 (patch)
tree5a3786664e41045eb3ff962dc055e0f25af1ac4b
parent831747ddb5886364707b696ac38cce8da333f15d (diff)
downloadstudy-player-6bf8907cd69383e8bb0a77b741d314bddf8008e6.tar.gz
study-player-6bf8907cd69383e8bb0a77b741d314bddf8008e6.zip
refactor: split single-file main.c into modular .h/.c pairs
Decompose the 778-line src/main.c into 7 focused modules with separate headers for code isolation and parallel agent development: types.h shared types: PlayerState, SilenceRegion, UILayout, constants player.{h,c} audio playback: load, play, pause, seek, update, format_time study.{h,c} study mode: silence detection, portion navigation, auto-pause ui.{h,c} rendering + input: fonts, colors, drawing, keyboard/mouse config.{h,c} UI layout persistence (moved UILayout to types.h) layout_editor.{h,c} layout editor tab (unchanged) main.c thin composition root (main loop, tabs, platform glue) Module boundaries follow the dispatch-project style: - .h files are contracts (self-contained, #pragma once, forward-declare) - .c files are private (static helpers, no cross-module .c includes) - All shared state passed by pointer (PlayerState*/UIState*) - font_data.h (xxd-generated array definition) included in exactly one .c Updated documentation (AGENTS.md, ORCHESTRATOR.md, GLOSSARY.md, README.md, tasks.md) and bin/ scripts (dispatch-style conventions, added bin/build-web). Build: make clean && make -j$(nproc) exits 0 with zero warnings.
-rw-r--r--.dispatch/build-agent.md9
-rw-r--r--.gitignore18
-rw-r--r--AGENTS.md13
-rw-r--r--GLOSSARY.md21
-rw-r--r--ORCHESTRATOR.md64
-rw-r--r--README.md53
-rwxr-xr-xbin/build41
-rwxr-xr-xbin/build-web85
-rwxr-xr-xbin/clean14
-rwxr-xr-xbin/serve14
-rw-r--r--src/config.h29
-rw-r--r--src/main.c801
-rw-r--r--src/player.c115
-rw-r--r--src/player.h30
-rw-r--r--src/study.c196
-rw-r--r--src/study.h45
-rw-r--r--src/types.h77
-rw-r--r--src/ui.c470
-rw-r--r--src/ui.h62
-rw-r--r--tasks.md61
20 files changed, 1345 insertions, 873 deletions
diff --git a/.dispatch/build-agent.md b/.dispatch/build-agent.md
index cbf21dd..1a6ed43 100644
--- a/.dispatch/build-agent.md
+++ b/.dispatch/build-agent.md
@@ -31,11 +31,20 @@ You MUST NOT write to `src/*` or any other source files.
- Font header generation: `build/font_data.h` is a prerequisite built by
running `xxd -i` on the font file in `resources/`. If no font file exists,
the build should still work (font_data.h just won't define `FONT_EMBEDDED`).
+ **`font_data.h` is an array definition, not a declaration** — it must be
+ included in exactly ONE `.c` file (currently `ui.c`). Multiple includes cause
+ multiple-definition link errors.
- The `bin/build-web` script builds for Web/WASM via `emcc` + `emar`. Keep it
functional.
- All SRCS should be `$(wildcard src/*.c)` so new modules auto-compile.
- Use `-j$(nproc)` in any make invocation inside scripts for parallelism.
+## bin/ script conventions
+- Every script starts with `#!/usr/bin/env bash` and `set -euo pipefail`.
+- Every script derives `SCRIPT_DIR` and `PROJECT_DIR` and `cd "$PROJECT_DIR"`.
+- Every script forwards extra args via `"$@"`.
+- One script per operation — the filename describes what it does.
+
## Verification
1. `make clean && make -j$(nproc)` — exits 0, zero warnings from project code
2. `make windows -j$(nproc)` — exits 0, zero warnings from project code
diff --git a/.gitignore b/.gitignore
index 02bd1f1..b584502 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,21 @@
+# Build artifacts
build/
+build-web/
+
+# External dependencies (cloned separately)
deps/
+
+# Runtime assets (fonts — embedded at build time)
resources/
+
+# Runtime config
+study-player.cfg
+
+# Agent harness scratch (orchestrator→agent prompts, agent→orchestrator reports)
+prompts/
+reports/
+
+# OS / editor
+.DS_Store
+*.swp
+*~
diff --git a/AGENTS.md b/AGENTS.md
index 9e9c600..99a2054 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,8 +15,11 @@
APIs (windowing, audio, input, font loading, drawing). Never include glfw,
miniaudio, or stb headers directly. For web: `emscripten.h` is allowed behind
`#ifdef PLATFORM_WEB`.
-- **`build/font_data.h`** (generated by `xxd` from `resources/`) is included
- behind `#include "font_data.h"` guard its use with `#if FONT_EMBEDDED`.
+- **`build/font_data.h`** (generated by `xxd` from `resources/`) is an
+ **array definition**, not a declaration. Include it in EXACTLY ONE `.c` file
+ (currently `ui.c`). Guard its use with `#if FONT_EMBEDDED`. Never include it
+ from a `.h` file or from multiple `.c` files — that causes multiple-definition
+ link errors.
- **No dynamic allocation** unless paired with an explicit free in the same
module's cleanup. No leaks.
- **No VLAs** (variable-length arrays on the stack). Use fixed-size buffers or
@@ -59,9 +62,9 @@
- **Build is the primary test.** If `make` exits 0 with zero warnings, your
module compiles and links correctly.
-- **If you add a new `.c` file**, note it in your report — the orchestrator
- updates the Makefile's `SRCS` list (this is orchestrator-owned build wiring,
- NOT your responsibility).
+- **If you add a new `.c` file**, note it in your report. The Makefile uses
+ `$(wildcard src/*.c)` so new modules are auto-discovered — no `SRCS` edit
+ needed. The orchestrator owns the Makefile for any structural changes.
## 6. What you may read from other modules
diff --git a/GLOSSARY.md b/GLOSSARY.md
index 4d05b8b..b78b66c 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -7,25 +7,30 @@
| Term | Definition | Avoid calling it... |
|------|-----------|-------------------|
-| **PlayerState** | The single struct holding all mutable runtime state: loaded audio, playback status, silence regions, study mode flag | "app state", "context", "global state" |
+| **PlayerState** | The single struct holding all mutable runtime state: loaded audio, playback status, silence regions, study mode flag. Defined in `types.h`. | "app state", "context", "global state" |
+| **UIState** | The struct holding UI-only state: fonts, colors, and interaction flags (smartPlayHeld). Defined in `ui.h`. | "ui context", "render state" |
+| **UILayout** | Pixel positions for every on-screen element, persisted to `study-player.cfg`. Defined in `types.h`, loaded/saved by the config module. | "layout config", "positions" |
| **speaking portion** | A contiguous segment of audio between two silence regions (i.e. the "meaningful content"). 0-based index. | "section", "segment", "clip", "part" |
| **silence region** | A detected gap in audio where amplitude stays below threshold for ≥ minDuration. Normalized 0–1. | "pause", "gap", "quiet zone" |
| **study mode** | Boolean toggle (`PlayerState.studyMode`). When ON, auto-pauses at silence boundaries. | "auto-pause mode", "learning mode" |
| **seek** | Jump playback position to a specific time (seconds). `player_seek(PlayerState*, float)`. | "scrub", "jump", "skip" |
-| **auto-pause** | The study-mode mechanism that pauses playback when entering a silence region. | "auto-stop", "silence break" |
+| **auto-pause** | The study-mode mechanism that pauses playback when entering a silence region. Implemented by `study_auto_pause_check`. | "auto-stop", "silence break" |
| **padding zone** | The 0.25s "breathing room" added around speaking portions (silence shrunk by 0.25s on each side) to avoid auto-pausing during natural speech pauses. | "grace period", "buffer zone" |
| **portion navigation** | Jumping between speaking portions via keys (V/B) or section buttons. | "chapter skip", "section nav" |
-| **embedded font** | A `.otf`/`.ttf` font file converted to `build/font_data.h` via `xxd`, compiled into the binary. Guarded by `#if FONT_EMBEDDED`. | "baked font", "built-in font" |
+| **smart play** | A hold-to-override button that resumes playback and suppresses auto-pause while held. State tracked in `UIState.smartPlayHeld`. | "hold play", "override button" |
+| **embedded font** | A `.otf`/`.ttf` font file converted to `build/font_data.h` via `xxd`, compiled into the binary. Guarded by `#if FONT_EMBEDDED`. Included in exactly one `.c` file (`ui.c`). | "baked font", "built-in font" |
## Module names
| Module | Files | Owns |
|--------|-------|------|
-| **types** | `src/types.h` | `PlayerState`, `SilenceRegion`, all enums, defines, constants |
-| **player** | `src/player.h`, `src/player.c` | Audio loading, playback control, seek, time formatting |
-| **study** | `src/study.h`, `src/study.c` | Silence detection, study mode logic, portion navigation |
-| **ui** | `src/ui.h`, `src/ui.c` | All rendering, font/color initialization, input handling |
-| **main** | `src/main.c` | Entry point, main loop, platform glue |
+| **types** | `src/types.h` | `PlayerState`, `SilenceRegion`, `UILayout`, all constants (`SCREEN_W`, `SCREEN_H`, `MAX_*`). Pure header — no `.c`. |
+| **player** | `src/player.h`, `src/player.c` | Audio loading, playback control, seek, music-stream update, time formatting |
+| **study** | `src/study.h`, `src/study.c` | Silence detection, speaking-portion navigation, auto-pause logic |
+| **ui** | `src/ui.h`, `src/ui.c` | Font/color initialization, all rendering, all input handling (player tab), `UIState` |
+| **config** | `src/config.h`, `src/config.c` | `UILayout` persistence (load/save to `study-player.cfg`) |
+| **layout_editor** | `src/layout_editor.h`, `src/layout_editor.c` | Layout editor tab (drag-to-reposition UI elements), raygui implementation |
+| **main** | `src/main.c` | Entry point, main loop, tab switching, drag-drop/web file loading, platform glue |
## Build terms
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
index b5d5319..4a41f68 100644
--- a/ORCHESTRATOR.md
+++ b/ORCHESTRATOR.md
@@ -37,15 +37,18 @@ the `.h` contract is underspecified — that is a bug, not normal.
declarations over pulling in heavy headers.
2. **No cross-module `.c` includes** — ever. If module A needs module B, A
includes `B.h`, never `B.c`.
-3. **All shared mutable state goes through `PlayerState*`** — no global
- variables. File-scope statics are only for module-private state (e.g. fonts
- in the UI module).
+3. **All shared mutable state goes through `PlayerState*`** (or `UIState*` for
+ UI-only state) — no global variables. File-scope statics are only for
+ module-private state (e.g. fonts in the ui module).
4. **One `.o` per module** — each `.c` compiles independently. The linker
resolves dependencies. This is what makes parallel-agent waves possible.
5. **Zero warnings on `-Wall -Wextra`** — the build is the trust signal. If
`make` barks, the wave is not green.
6. **Raylib is the only external dependency** — no pulling in new libraries
without a design decision.
+7. **`font_data.h` is a definition, not a declaration** — include it in exactly
+ ONE `.c` file (currently `ui.c`). Including it from multiple translation
+ units causes multiple-definition link errors.
---
@@ -236,13 +239,19 @@ authoritative.
single file owns it. Summon a **temporary multi-knowledge agent** with
read/write to the 2–3 relevant files (it MAY see `.c` files — exception to
the visibility rule), as their temporary exclusive owner.
+- **Multiple-definition link error on `embedded_font_data`:** `font_data.h` is
+ an `xxd`-generated array definition, not a declaration. It must be included
+ in exactly ONE `.c` file (currently `ui.c`). If a new module needs the font
+ data, either route it through `ui.h` functions or move the include to a
+ single owner and expose `extern` declarations.
- **CR (change-request) in a report:** if it's **build/config** (`Makefile`,
`.gitignore`, `deps/` reference) the orchestrator edits it directly, then
re-verifies with `make`. If it's **implementation** (a `.c` file), the
orchestrator **summons the owning agent** — it does NOT edit `.c` files
itself.
- **Makefile changes:** the Makefile is orchestrator-owned (it's build wiring,
- §6). If a module addition requires updating `SRCS`, the orchestrator does it.
+ §6). It uses `$(wildcard src/*.c)` so new modules auto-compile. Structural
+ changes (new targets, new platforms) are orchestrator-owned.
---
@@ -296,9 +305,9 @@ authoritative.
windows` cross-compiles for Windows via MinGW. Both platforms must work.
Use `#ifdef PLATFORM_LINUX` / `_GLFW_X11` vs `_GLFW_WIN32` guards where
platform differences exist.
-- **No global mutable state.** All shared state passes through `PlayerState*`.
- File-scope statics are for module-private data only (e.g. cached fonts in the
- UI module).
+- **No global mutable state.** All shared state passes through `PlayerState*`
+ (or `UIState*` for UI-only state). File-scope statics are for module-private
+ data only.
- **C99 only.** No C11/C17 features the compiler doesn't support. No C++
in `.c` files.
- **Raylib is the only external library.** No SDL, no GLFW standalone, no
@@ -315,7 +324,7 @@ authoritative.
ORCHESTRATOR.md the orchestrator's operating manual (this file)
GLOSSARY.md canonical vocabulary + aliases-to-avoid
tasks.md live progress checklist / milestone log
- Makefile build — orchestrator-owned, never touched by agents
+ Makefile build — orchestrator-owned, never touched by module agents
README.md project overview, build instructions, usage guide
.dispatch/
@@ -326,14 +335,6 @@ authoritative.
zero-warnings.md
contracts-are-h.md
- .rules/ original design plans (reference only)
- plan/
- plan.md
- phase1.md
- phase2.md
- phase3.md
- ideas/
-
notes/
restructure-plan.md the full module split design + rationale + wave plan
@@ -341,21 +342,25 @@ authoritative.
reports/ (gitignored — agent→orchestrator reports)
src/
- types.h CONTRACT — shared types, enums, constants (PlayerState, etc.)
- player.h CONTRACT — audio playback: load, seek, play, pause, format_time
+ types.h CONTRACT — shared types, enums, constants (PlayerState, SilenceRegion, UILayout)
+ player.h CONTRACT — audio playback: load, seek, play, pause, update, format_time
player.c IMPL
- study.h CONTRACT — study mode: detect_silence, portion navigation
+ study.h CONTRACT — study mode: detect_silence, portion navigation, auto-pause
study.c IMPL
- ui.h CONTRACT — rendering: init, render_frame, destroy
- ui.c IMPL
- main.c COMPOSITION ROOT — entry point + main loop
+ ui.h CONTRACT — rendering + input: init, destroy, handle_input, render_player, render_empty
+ ui.c IMPL (owns font_data.h include)
+ config.h CONTRACT — UILayout persistence: config_load, config_save
+ config.c IMPL
+ layout_editor.h CONTRACT — layout editor tab: init, draw
+ layout_editor.c IMPL (owns raygui implementation)
+ main.c COMPOSITION ROOT — entry point + main loop + platform glue
deps/
raylib/ raylib library (built as static lib)
- raygui/ raygui library (reserved, not yet used)
+ raygui/ raygui library (single-header, implemented in layout_editor.c)
bin/
- build build script for desktop (Windows cross-compile)
+ build build script for desktop (Linux native / Windows cross-compile)
build-web build script for WASM/web
clean clean build artifacts
serve serve web build locally
@@ -370,9 +375,9 @@ authoritative.
## 8. Current status & how to run
-See `tasks.md` for the live checklist. The project is a working single-file
-`src/main.c` (778 lines) that needs to be split into modules as described in
-`notes/restructure-plan.md`.
+See `tasks.md` for the live checklist. The module split is **complete** — the
+single-file `src/main.c` (778 lines) has been decomposed into 7 modules:
+`types.h`, `player`, `study`, `ui`, `config`, `layout_editor`, `main`.
**Desktop build:**
```bash
@@ -398,6 +403,5 @@ make clean && make -j$(nproc)
bin/clean # removes build/ and build-web/
```
-When the module restructure is complete, `make` builds for the host platform.
-`make windows` cross-compiles for Windows via MinGW. The font header generation
-is a make prerequisite.
+The font header generation is a make prerequisite — `build/font_data.h` is
+generated by `xxd -i` from the first `.otf`/`.ttf` in `resources/`.
diff --git a/README.md b/README.md
index d701b93..e4d0043 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,25 @@ A keyboard-driven MP3 player built with [raylib](https://github.com/raysan5/rayl
<img width="1923" height="1125" alt="image" src="https://github.com/user-attachments/assets/a2e2363e-cc0a-4936-8b04-0f32886ff9d8" />
+## Architecture
+
+The project is built from composable modules, each a `.h` contract + `.c`
+implementation pair. This separation enables parallel agent development —
+modules communicate only through header-file contracts.
+
+```
+src/types.h shared types: PlayerState, SilenceRegion, UILayout, constants
+src/player.{h,c} audio playback: load, play, pause, seek, update, format_time
+src/study.{h,c} study mode: silence detection, portion navigation, auto-pause
+src/ui.{h,c} rendering + input: fonts, colors, drawing, keyboard/mouse
+src/config.{h,c} UI layout persistence (study-player.cfg)
+src/layout_editor.{h,c} layout editor tab (drag-to-reposition)
+src/main.c composition root: main loop, tab switching, platform glue
+```
+
+See `GLOSSARY.md` for the canonical vocabulary and `ORCHESTRATOR.md` for the
+multi-agent development workflow.
+
### Dependencies
Clone the following repositories into the `deps/` directory:
@@ -35,14 +54,27 @@ If no font file is present, the app falls back to the built-in raylib default fo
### Building
```bash
-bin/build
+# Linux native
+make -j$(nproc)
+
+# Windows cross-compile
+make windows -j$(nproc)
```
-The output binary is `build/study-player.exe`.
+The output binary is `build/study-player` (Linux) or `build/study-player.exe` (Windows).
+
+Alternatively, use the convenience scripts:
+
+```bash
+bin/build # desktop build (Linux native + Windows cross-compile)
+bin/clean # remove build artifacts
+bin/build-web # WASM/web build via emscripten
+bin/serve # serve web build on port 8080
+```
### Usage
-Run the `.exe` on Windows (or under Wine). Drag an `.mp3` file onto the window to load it.
+Run the binary on Linux (or the `.exe` on Windows / under Wine). Drag an `.mp3` file onto the window to load it.
| Key | Action |
|---|---|
@@ -89,10 +121,13 @@ When Study Mode is **off**, C pauses and N resumes without any section-seeking b
### Project Structure
```
-src/main.c Application source
-deps/raylib/ raylib (cloned separately)
-deps/raygui/ raygui (cloned separately)
-build/ Build output (gitignored)
-resources/ Font files (gitignored)
-bin/ Build and utility scripts
+src/ modular C source (one .h/.c pair per module)
+deps/raylib/ raylib (cloned separately)
+deps/raygui/ raygui (cloned separately)
+build/ Build output (gitignored)
+resources/ Font files (gitignored)
+bin/ Build and utility scripts
+AGENTS.md Subagent constitution (C99 rules, module boundaries)
+ORCHESTRATOR.md Multi-agent orchestration workflow
+GLOSSARY.md Canonical vocabulary
```
diff --git a/bin/build b/bin/build
index e3f5200..d27d6f6 100755
--- a/bin/build
+++ b/bin/build
@@ -1,32 +1,21 @@
#!/usr/bin/env bash
+# bin/build — desktop build (Linux native + Windows cross-compile)
+#
+# Usage:
+# bin/build # Linux native build
+# bin/build windows # Windows cross-compile
+# bin/build clean # clean + build
+#
+# Any args after the target are forwarded to make.
+
set -euo pipefail
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-cd "$SCRIPT_DIR"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-# Generate embedded font header from first font file in resources/
-mkdir -p build
-FONT_HEADER="build/font_data.h"
-FONT_FILE="$(find resources/ -maxdepth 1 -type f \( -iname '*.otf' -o -iname '*.ttf' \) 2>/dev/null | head -n1 || true)"
+cd "$PROJECT_DIR"
-if [ -n "$FONT_FILE" ]; then
- if [ ! -f "$FONT_HEADER" ] || [ "$FONT_FILE" -nt "$FONT_HEADER" ]; then
- echo "Embedding font: $FONT_FILE"
- xxd -i "$FONT_FILE" > "$FONT_HEADER.tmp"
- # Normalize variable names to embedded_font_data / embedded_font_data_len
- VARNAME=$(grep -oP 'unsigned char \K[a-zA-Z0-9_]+' "$FONT_HEADER.tmp" | head -1)
- sed -i "s/${VARNAME}/embedded_font_data/g" "$FONT_HEADER.tmp"
- echo '#define FONT_EMBEDDED 1' >> "$FONT_HEADER.tmp"
- mv "$FONT_HEADER.tmp" "$FONT_HEADER"
- fi
-else
- echo "No font file found in resources/, using default raylib font"
- cat > "$FONT_HEADER" <<'EOF'
-/* No font embedded */
-static unsigned char embedded_font_data[] = {0};
-static unsigned int embedded_font_data_len = 0;
-#define FONT_EMBEDDED 0
-EOF
-fi
+TARGET="${1:-all}"
+shift || true
-make -j$(nproc) "$@"
+make "$TARGET" -j"$(nproc)" "$@"
diff --git a/bin/build-web b/bin/build-web
new file mode 100755
index 0000000..453ee92
--- /dev/null
+++ b/bin/build-web
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# bin/build-web — WASM/web build via Emscripten
+#
+# Produces build-web/index.{html,js,wasm} from src/ + web/shell.html.
+# Requires emcc + emar (Emscripten SDK) on PATH.
+#
+# Usage:
+# bin/build-web
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+cd "$PROJECT_DIR"
+
+SRC_DIR="src"
+BUILD_DIR="build-web"
+RAYLIB_SRC="deps/raylib/src"
+RAYLIB_LIB="$BUILD_DIR/libraylib.a"
+
+COMMON_INCS="-I$BUILD_DIR -I$RAYLIB_SRC -I$RAYLIB_SRC/external/glfw/include -Ideps/raygui/src"
+DEFINES="-DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2"
+CFLAGS="-std=c99 -O2 $COMMON_INCS $DEFINES"
+
+mkdir -p "$BUILD_DIR"
+
+# --- Font header (same logic as Makefile) ---
+FONT_HEADER="$BUILD_DIR/font_data.h"
+FONT_FILE="$(find resources -maxdepth 1 -type f \( -iname '*.otf' -o -iname '*.ttf' \) -print -quit 2>/dev/null || true)"
+
+if [ -n "$FONT_FILE" ] && command -v xxd >/dev/null 2>&1; then
+ echo " XXD $(basename "$FONT_FILE") -> $FONT_HEADER"
+ xxd -i "$FONT_FILE" > "$FONT_HEADER.tmp"
+ VARNAME="$(grep -oP 'unsigned char \K[a-zA-Z0-9_]+' "$FONT_HEADER.tmp" | head -1)"
+ sed -i "s/${VARNAME}/embedded_font_data/g" "$FONT_HEADER.tmp"
+ echo '#define FONT_EMBEDDED 1' >> "$FONT_HEADER.tmp"
+ mv "$FONT_HEADER.tmp" "$FONT_HEADER"
+else
+ echo " INFO No font file or xxd unavailable — using default font"
+ printf '/* No font embedded */\nstatic unsigned char embedded_font_data[] = {0};\nstatic unsigned int embedded_font_data_len = 0;\n#define FONT_EMBEDDED 0\n' > "$FONT_HEADER"
+fi
+
+# --- Build raylib for web ---
+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_OBJS=""
+for src in $RAYLIB_SRCS; do
+ obj="$BUILD_DIR/raylib_$(basename "${src%.c}").o"
+ echo " CC $src"
+ emcc $CFLAGS -w -c -o "$obj" "$src"
+ RAYLIB_OBJS="$RAYLIB_OBJS $obj"
+done
+
+echo " AR $RAYLIB_LIB"
+emar rcs "$RAYLIB_LIB" $RAYLIB_OBJS
+
+# --- Build application ---
+APP_SRCS="$(find "$SRC_DIR" -name '*.c')"
+APP_OBJS=""
+for src in $APP_SRCS; do
+ obj="$BUILD_DIR/$(basename "${src%.c}").o"
+ echo " CC $src"
+ emcc $CFLAGS -Wall -Wextra -c -o "$obj" "$src"
+ APP_OBJS="$APP_OBJS $obj"
+done
+
+# --- Link ---
+SHELL_HTML="web/shell.html"
+if [ ! -f "$SHELL_HTML" ]; then
+ echo "Error: $SHELL_HTML not found" >&2
+ exit 1
+fi
+
+echo " LINK $BUILD_DIR/index.html"
+emcc $CFLAGS -o "$BUILD_DIR/index.html" $APP_OBJS "$RAYLIB_LIB" \
+ -s USE_GLFW=3 \
+ -s WASM=1 \
+ -s ASYNCIFY \
+ -s SHELL_FILE="$PWD/$SHELL_HTML" \
+ -s EXPORTED_FUNCTIONS='["_main","_load_file_web"]' \
+ -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
+ --preload-file resources \
+ "$@"
+
+echo "Build complete: $BUILD_DIR/index.html"
diff --git a/bin/clean b/bin/clean
index 91ee73b..202ee9d 100755
--- a/bin/clean
+++ b/bin/clean
@@ -1,7 +1,15 @@
#!/usr/bin/env bash
+# bin/clean — remove all build artifacts
+#
+# Usage:
+# bin/clean
+
set -euo pipefail
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-cd "$SCRIPT_DIR"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+cd "$PROJECT_DIR"
-make clean
+rm -rf build build-web
+echo "Cleaned build/ and build-web/"
diff --git a/bin/serve b/bin/serve
index 931e2db..cb6fb06 100755
--- a/bin/serve
+++ b/bin/serve
@@ -1,13 +1,21 @@
#!/usr/bin/env bash
+# bin/serve — serve the web build locally
+#
+# Usage:
+# bin/serve # serve on port 8080
+# bin/serve 9000 # serve on port 9000
+
set -euo pipefail
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-cd "$SCRIPT_DIR"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+cd "$PROJECT_DIR"
BUILD_DIR="build-web"
if [ ! -f "$BUILD_DIR/index.html" ]; then
- echo "Error: Web build not found. Run 'bin/build-web' first."
+ echo "Error: Web build not found. Run 'bin/build-web' first." >&2
exit 1
fi
diff --git a/src/config.h b/src/config.h
index 57824b5..42ccca2 100644
--- a/src/config.h
+++ b/src/config.h
@@ -1,24 +1,15 @@
#pragma once
-typedef struct {
- float titleY;
- float titleX;
- float barY;
- float barHeight;
- float barWidth;
- float barX;
- float statusY;
- float statusX;
- float btnRadius;
- float helpY;
- float helpX;
- float btnY;
- float btnCenterX;
- float smartPlayY;
- float smartPlayX;
- float secNavY;
- float secNavX;
-} UILayout;
+/* config.h — UI layout persistence contract.
+ *
+ * Owns: loading/saving UILayout to study-player.cfg.
+ * UILayout itself lives in types.h (the shared-types header). */
+#include "types.h"
+
+/* Load layout from <exeDir>/study-player.cfg; returns 1 if loaded, 0 if
+ * the file was missing or unreadable (defaults are applied either way). */
int config_load(const char *exePath, UILayout *layout);
+
+/* Save layout to <exeDir>/study-player.cfg; returns 1 on success. */
int config_save(const char *exePath, const UILayout *layout);
diff --git a/src/main.c b/src/main.c
index 49d1ab2..91dc984 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,12 +1,23 @@
+/* main.c — composition root.
+ *
+ * Owns: window/audio init, main loop, tab switching + config save,
+ * drag-drop / web file loading, platform glue.
+ * Delegates to: player, study, ui, config, layout_editor. */
+
#define _POSIX_C_SOURCE 200809L
+
#include "raylib.h"
-#include <string.h>
-#include <ctype.h>
+#include "raygui.h"
+
#include <stdio.h>
+#include <string.h>
+
+#include "types.h"
#include "config.h"
#include "layout_editor.h"
-#include "raygui.h"
-#include "font_data.h"
+#include "player.h"
+#include "study.h"
+#include "ui.h"
#ifdef PLATFORM_LINUX
#include <unistd.h>
@@ -15,321 +26,27 @@
#include <emscripten/emscripten.h>
#endif
-#define SCREEN_W 1920
-#define SCREEN_H 1080
-
-#define MAX_SILENCE_REGIONS 4096
+/* ------------------------------------------------------------------ */
+/* Shared state (needed for emscripten main loop callback) */
+/* ------------------------------------------------------------------ */
-typedef struct {
- float start; /* normalized 0..1 */
- float end; /* normalized 0..1 */
-} SilenceRegion;
-
-typedef struct {
- Music music;
- bool loaded;
- bool playing;
- float duration;
- float currentTime;
- char filename[256];
- SilenceRegion silence[MAX_SILENCE_REGIONS];
- int silenceCount;
- bool studyMode;
- bool wasInSilence; /* for detecting silence entry */
- int lastSilenceIdx; /* index of silence region we were last in, or -1 */
- int skipAutoUpdate; /* frames to skip auto-updating currentTime */
- double lastVPress; /* unused, kept for struct compat */
-} PlayerState;
-
-/* --- File-scope state (needed for emscripten main loop callback) --- */
static PlayerState state = { 0 };
+static UIState ui;
+static UILayout layout;
+static char exeDir[512];
+static int activeTab = 0;
+static int prevTab = 0;
-static Font fontSmall, font, fontMed, fontLarge, fontHelp;
-static float szSmall, szHelp, szFont, szMed, szLarge;
-
-static Color bgColor;
-static Color textColor;
-static Color accentColor;
-static Color mutedColor;
-static Color barBgColor;
-static Color btnHoverColor;
-
-static UILayout layout;
-
-static int activeTab = 0;
-static int prevTab = 0;
-static bool smartPlayHeld = false;
-static char exeDir[512];
-
-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 void detect_silence(const char *path, PlayerState *s, float threshold, float minDuration)
-{
- s->silenceCount = 0;
- Wave wave = LoadWave(path);
- if (wave.data == NULL || wave.frameCount == 0) return;
-
- /* Convert to 32-bit float mono for easy analysis */
- WaveFormat(&wave, wave.sampleRate, 32, 1);
- float *samples = (float *)wave.data;
- unsigned int totalFrames = wave.frameCount;
- float sampleRate = (float)wave.sampleRate;
-
- /* Scan in chunks of ~10ms */
- int chunkSize = (int)(sampleRate * 0.01f);
- if (chunkSize < 1) chunkSize = 1;
- float minFrames = minDuration * sampleRate;
-
- bool inSilence = false;
- unsigned int silenceStart = 0;
-
- for (unsigned int i = 0; i < totalFrames; i += chunkSize)
- {
- unsigned int end = i + chunkSize;
- if (end > totalFrames) end = totalFrames;
-
- /* Find peak amplitude in this chunk */
- float peak = 0.0f;
- for (unsigned int j = i; j < end; j++)
- {
- float v = samples[j];
- if (v < 0) v = -v;
- if (v > peak) peak = v;
- }
-
- if (peak < threshold)
- {
- if (!inSilence) { silenceStart = i; inSilence = true; }
- }
- else
- {
- if (inSilence)
- {
- unsigned int len = i - silenceStart;
- if ((float)len >= minFrames && s->silenceCount < MAX_SILENCE_REGIONS)
- {
- s->silence[s->silenceCount].start = (float)silenceStart / (float)totalFrames;
- s->silence[s->silenceCount].end = (float)i / (float)totalFrames;
- s->silenceCount++;
- }
- inSilence = false;
- }
- }
- }
- /* Close any trailing silence */
- if (inSilence)
- {
- unsigned int len = totalFrames - silenceStart;
- if ((float)len >= minFrames && s->silenceCount < MAX_SILENCE_REGIONS)
- {
- s->silence[s->silenceCount].start = (float)silenceStart / (float)totalFrames;
- s->silence[s->silenceCount].end = (float)totalFrames / (float)totalFrames;
- s->silenceCount++;
- }
- }
-
- UnloadWave(wave);
-
- /* Pad speaking portions by shrinking silence regions 0.25s on each side */
- if (s->duration > 0.0f)
- {
- float padNorm = 0.25f / s->duration; /* 0.25s in normalized units */
- for (int i = 0; i < s->silenceCount; i++)
- {
- s->silence[i].start += padNorm;
- s->silence[i].end -= padNorm;
- if (s->silence[i].start >= s->silence[i].end)
- {
- /* Region too small after padding, remove it */
- for (int j = i; j < s->silenceCount - 1; j++)
- s->silence[j] = s->silence[j + 1];
- s->silenceCount--;
- i--;
- }
- }
- }
-}
-
-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);
- s->currentTime = target;
- s->skipAutoUpdate = 3; /* skip a few frames to let audio engine catch up */
-}
+/* ------------------------------------------------------------------ */
+/* File loading (drag-drop desktop / JS callback web) */
+/* ------------------------------------------------------------------ */
-static void format_time(float seconds, char *buf, int bufsize)
-{
- int total = (int)seconds;
- if (total < 0) total = 0;
- int h = total / 3600;
- int m = (total % 3600) / 60;
- int s = total % 60;
- if (h > 0)
- snprintf(buf, bufsize, "%d:%02d:%02d", h, m, s);
- else
- snprintf(buf, bufsize, "%d:%02d", m, s);
-}
-
-/* Find the silence region index containing the normalized position, or -1 */
-static int find_silence_at(PlayerState *s, float pos)
-{
- for (int i = 0; i < s->silenceCount; i++)
- if (pos >= s->silence[i].start && pos < s->silence[i].end) return i;
- return -1;
-}
-
-/* Get the start of speaking portion N (0-based). Returns normalized position. */
-static float speaking_portion_start(PlayerState *s, int portion)
-{
- if (portion <= 0) return 0.0f;
- if (portion > s->silenceCount) return s->silence[s->silenceCount - 1].end;
- return s->silence[portion - 1].end;
-}
-
-/* Get which speaking portion (0-based) the normalized position is in.
- During silence, returns the previous speaking portion. */
-static int current_speaking_portion(PlayerState *s, float pos)
-{
- int portion = 0;
- for (int i = 0; i < s->silenceCount; i++)
- {
- if (pos >= s->silence[i].end)
- portion = i + 1;
- else
- break;
- }
- return portion;
-}
-
-/* Total number of speaking portions */
-static int total_speaking_portions(PlayerState *s)
-{
- return s->silenceCount + 1;
-}
-
-/* Get the seek target (in seconds) for jumping to a speaking portion.
- Lands 2 render-frames (~33ms) into the padding zone. */
-static float segment_seek_target(PlayerState *s, int portion)
-{
- float pos = speaking_portion_start(s, portion);
- float target = pos * s->duration + (2.0f / 60.0f);
- if (target < 0.0f) target = 0.0f;
- if (target > s->duration) target = s->duration;
- return target;
-}
-
-/* Check if normalized position is in the padding zone of a speaking portion.
- The padding zone is [speaking_start, speaking_start + 0.25s/duration]. */
-static bool in_padding_zone(PlayerState *s, float pos, int portion)
-{
- if (s->duration <= 0.0f) return false;
- float padNorm = 0.25f / s->duration;
- float start = speaking_portion_start(s, portion);
- return (pos >= start && pos < start + padNorm);
-}
-
-static void draw_text_centered(Font f, const char *text, float centerX, float y, float fontSize, Color color)
-{
- float spacing = fontSize * 0.03f;
- Vector2 size = MeasureTextEx(f, text, fontSize, spacing);
- float x = centerX - size.x / 2.0f;
- DrawTextEx(f, text, (Vector2){ x, y }, fontSize, spacing, color);
-}
-
-/* Draw a right-pointing triangle (play icon) centered at cx,cy */
-static void draw_play_icon(float cx, float cy, float size, Color color)
-{
- float half = size / 2.0f;
- Vector2 v1 = { cx - half * 0.7f, cy - half };
- Vector2 v2 = { cx - half * 0.7f, cy + half };
- Vector2 v3 = { cx + half * 0.8f, cy };
- DrawTriangle(v1, v2, v3, color);
-}
-
-/* Draw two vertical bars (pause icon) centered at cx,cy */
-static void draw_pause_icon(float cx, float cy, float size, Color color)
-{
- float half = size / 2.0f;
- float barW = size * 0.25f;
- float gap = size * 0.15f;
- DrawRectangleRec((Rectangle){ cx - gap - barW, cy - half, barW, size }, color);
- DrawRectangleRec((Rectangle){ cx + gap, cy - half, barW, size }, color);
-}
-
-/* Draw a left-pointing triangle (seek back) centered at cx,cy */
-static void draw_seek_back_icon(float cx, float cy, float size, Color color)
-{
- float half = size / 2.0f;
- Vector2 v1 = { cx + half * 0.7f, cy - half };
- Vector2 v2 = { cx - half * 0.8f, cy };
- Vector2 v3 = { cx + half * 0.7f, cy + half };
- DrawTriangle(v1, v2, v3, color);
-}
-
-/* Draw a right-pointing triangle (seek forward) centered at cx,cy */
-static void draw_seek_fwd_icon(float cx, float cy, float size, Color color)
-{
- float half = size / 2.0f;
- Vector2 v1 = { cx - half * 0.7f, cy - half };
- Vector2 v2 = { cx - half * 0.7f, cy + half };
- Vector2 v3 = { cx + half * 0.8f, cy };
- DrawTriangle(v1, v2, v3, color);
-}
-
-static bool button_hit(float cx, float cy, float radius)
-{
- if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) return false;
- Vector2 m = GetMousePosition();
- float dx = m.x - cx;
- float dy = m.y - cy;
- return (dx * dx + dy * dy) <= (radius * radius);
-}
-
-/* Load an audio file into the player (used by both desktop drag-drop and web file input) */
static void load_audio_file(const char *path)
{
- const char *ext = GetFileExtension(path);
- if (ext == NULL || strcasecmp_ext(ext, ".mp3") != 0) return;
-
- 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);
+ if (!player_load(&state, path)) return;
/* Detect silence regions (threshold: 0.015, min duration: 0.75s) */
- detect_silence(path, &state, 0.015f, 0.75f);
+ study_detect_silence(path, &state, 0.015f, 0.75f);
char titleBuf[320];
snprintf(titleBuf, sizeof(titleBuf), "Study Player - %s", state.filename);
@@ -345,10 +62,13 @@ void load_file_web(const char *path)
}
#endif
-/* --- Main loop body (one frame) --- */
+/* ------------------------------------------------------------------ */
+/* Main loop body (one frame) */
+/* ------------------------------------------------------------------ */
+
static void update_frame(void)
{
- /* --- 1.1 Drag & drop file loading (desktop only) --- */
+ /* --- Drag & drop file loading (desktop only) --- */
#ifndef PLATFORM_WEB
if (IsFileDropped())
{
@@ -359,394 +79,35 @@ static void update_frame(void)
}
#endif
+ /* --- Input (player tab only) --- */
if (activeTab == 0)
- {
- /* --- Button clicks --- */
- if (state.loaded)
- {
- /* Play/pause button */
- if (button_hit(layout.btnCenterX, layout.btnY, layout.btnRadius))
- {
- if (state.playing)
- {
- PauseMusicStream(state.music);
- state.playing = false;
- }
- else
- {
- ResumeMusicStream(state.music);
- state.playing = true;
- }
- }
-
- /* Section nav buttons */
- {
- float progress = (state.duration > 0.0f) ? state.currentTime / state.duration : 0.0f;
- int portion = current_speaking_portion(&state, progress) + 1;
- int total = total_speaking_portions(&state);
- if (portion > total) portion = total;
- float secBtnRadius = 35.0f;
- float secPrevX = layout.secNavX - 65.0f;
- float secNextX = layout.secNavX + 65.0f;
- float secBtnY = layout.secNavY;
-
- if (button_hit(secPrevX, secBtnY, secBtnRadius))
- {
- float pos = state.currentTime / state.duration;
- int p = current_speaking_portion(&state, pos);
- bool inSil = (find_silence_at(&state, pos) >= 0);
- bool inPad = in_padding_zone(&state, pos, p);
- if ((inSil || inPad) && p > 0) p--;
- float target = segment_seek_target(&state, p);
- seek_to(&state, target);
- state.wasInSilence = false;
- state.lastSilenceIdx = -1;
- }
- if (button_hit(secNextX, secBtnY, secBtnRadius))
- {
- float pos = state.currentTime / state.duration;
- int p = current_speaking_portion(&state, pos);
- if (p < total - 1) p++;
- float target = segment_seek_target(&state, p);
- seek_to(&state, target);
- state.wasInSilence = false;
- state.lastSilenceIdx = -1;
- }
- }
-
- /* --- Smart play hold button input --- */
- {
- Rectangle smartBtn = { layout.smartPlayX, layout.smartPlayY, 200.0f, 80.0f };
- Vector2 mouse = GetMousePosition();
- bool overBtn = (mouse.x >= smartBtn.x && mouse.x <= smartBtn.x + smartBtn.width &&
- mouse.y >= smartBtn.y && mouse.y <= smartBtn.y + smartBtn.height);
-
- if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && overBtn) {
- smartPlayHeld = true;
- if (!state.playing) {
- ResumeMusicStream(state.music);
- state.playing = true;
- }
- }
+ ui_handle_input(&ui, &state, &layout);
- if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && smartPlayHeld) {
- /* Allow finger drift - stay held */
- }
-
- if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) {
- smartPlayHeld = false;
- }
-
- if (GetTouchPointCount() > 0 && smartPlayHeld) {
- /* Touch fallback: keep held while touch points active */
- }
- }
- }
-
- /* --- Keyboard input --- */
- if (state.loaded && IsKeyPressed(KEY_C) && state.playing)
- {
- PauseMusicStream(state.music);
- state.playing = false;
- }
-
- if (state.loaded && IsKeyPressed(KEY_N) && !state.playing)
- {
- float pos = state.currentTime / state.duration;
- int portion = current_speaking_portion(&state, pos);
- float target = segment_seek_target(&state, portion);
- seek_to(&state, target);
- ResumeMusicStream(state.music);
- state.playing = true;
- }
-
- if (state.loaded && IsKeyPressed(KEY_SPACE) && !state.playing)
- {
- ResumeMusicStream(state.music);
- state.playing = true;
- }
-
- if (state.loaded && IsKeyPressed(KEY_V))
- {
- float pos = state.currentTime / state.duration;
- int portion = current_speaking_portion(&state, pos);
- bool inSil = (find_silence_at(&state, pos) >= 0);
- bool inPad = in_padding_zone(&state, pos, portion);
- if ((inSil || inPad) && portion > 0)
- portion--;
- float target = segment_seek_target(&state, portion);
- seek_to(&state, target);
- state.wasInSilence = false;
- state.lastSilenceIdx = -1;
- }
-
- if (state.loaded && IsKeyPressed(KEY_B))
- {
- float pos = state.currentTime / state.duration;
- int portion = current_speaking_portion(&state, pos);
- int total = total_speaking_portions(&state);
- if (portion < total - 1) portion++;
- float target = segment_seek_target(&state, portion);
- seek_to(&state, target);
- state.wasInSilence = false;
- state.lastSilenceIdx = -1;
- }
-
- /* Click-to-seek on progress bar */
- if (state.loaded && IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
- {
- Vector2 mouse = GetMousePosition();
- if (mouse.x >= layout.barX && mouse.x <= layout.barX + layout.barWidth &&
- mouse.y >= layout.barY && mouse.y <= layout.barY + layout.barHeight)
- {
- float target = ((mouse.x - layout.barX) / layout.barWidth) * state.duration;
- seek_to(&state, target);
- }
- }
-
- /* Arrow key seeking */
- if (state.loaded && IsKeyPressed(KEY_LEFT))
- seek_to(&state, state.currentTime - 5.0f);
- if (state.loaded && IsKeyPressed(KEY_RIGHT))
- seek_to(&state, state.currentTime + 5.0f);
- if (state.loaded && IsKeyPressed(KEY_UP) && !state.playing)
- {
- ResumeMusicStream(state.music);
- state.playing = true;
- }
- if (state.loaded && IsKeyPressed(KEY_DOWN) && state.playing)
- {
- PauseMusicStream(state.music);
- float rewind = state.currentTime - 1.0f;
- if (rewind < 0.0f) rewind = 0.0f;
- SeekMusicStream(state.music, rewind);
- state.currentTime = rewind;
- state.playing = false;
- }
- /* Number key seeking (0-9 = 0%-90%) */
+ /* --- Music stream update + study auto-pause --- */
if (state.loaded)
{
- for (int k = 0; k <= 9; k++)
- {
- if (IsKeyPressed(KEY_ZERO + k))
- {
- float target = state.duration * (k / 10.0f);
- seek_to(&state, target);
- break;
- }
- }
- }
- }
-
- /* --- Music stream update --- */
- if (state.loaded)
- {
- UpdateMusicStream(state.music);
- if (state.playing)
- {
- if (state.skipAutoUpdate > 0)
- {
- state.skipAutoUpdate--;
- }
- else
- {
- state.currentTime = GetMusicTimePlayed(state.music);
- }
-
- /* Study mode: auto-pause at silence boundaries */
- if (state.studyMode && state.duration > 0.0f)
- {
- float pos = state.currentTime / state.duration;
- int silIdx = find_silence_at(&state, pos);
- bool nowInSilence = (silIdx >= 0);
-
- if (nowInSilence && !state.wasInSilence && !IsKeyDown(KEY_SPACE) && !smartPlayHeld)
- {
- float target = segment_seek_target(&state, silIdx + 1);
- PauseMusicStream(state.music);
- SeekMusicStream(state.music, target);
- state.currentTime = target;
- state.playing = false;
- state.skipAutoUpdate = 3;
- }
- else if (!nowInSilence && state.wasInSilence && !IsKeyDown(KEY_SPACE) && !smartPlayHeld)
- {
- int portion = current_speaking_portion(&state, pos);
- float target = segment_seek_target(&state, portion);
- PauseMusicStream(state.music);
- SeekMusicStream(state.music, target);
- state.currentTime = target;
- state.playing = false;
- state.skipAutoUpdate = 3;
- }
-
- state.wasInSilence = nowInSilence;
- if (nowInSilence) state.lastSilenceIdx = silIdx;
- }
- }
+ player_update(&state);
+ if (state.studyMode && state.playing)
+ study_auto_pause_check(&state, ui.smartPlayHeld, IsKeyDown(KEY_SPACE));
}
/* --- Save layout on tab switch --- */
- if (prevTab == 1 && activeTab == 0) {
+ if (prevTab == 1 && activeTab == 0)
config_save(exeDir, &layout);
- }
prevTab = activeTab;
/* --- Drawing --- */
BeginDrawing();
- ClearBackground(bgColor);
+ ClearBackground(ui.bgColor);
char *tabNames[] = { "Player", "Layout" };
GuiTabBar((Rectangle){ 0, 10, SCREEN_W, 32 }, tabNames, 2, &activeTab);
if (activeTab == 0) {
- if (state.loaded)
- {
- draw_text_centered(font, state.filename, layout.titleX, layout.titleY, szFont, mutedColor);
-
- /* Progress bar */
- float currentTime = state.currentTime;
- float progress = (state.duration > 0.0f) ? currentTime / state.duration : 0.0f;
- if (progress > 1.0f) progress = 1.0f;
-
- Rectangle barBg = { layout.barX, layout.barY, layout.barWidth, layout.barHeight };
- Rectangle barFill = { layout.barX, layout.barY, layout.barWidth * progress, layout.barHeight };
- DrawRectangleRounded(barBg, 0.4f, 8, barBgColor);
- if (progress > 0.001f)
- DrawRectangleRounded(barFill, 0.4f, 8, accentColor);
-
- /* Time labels */
- char timeBuf[16];
- int elapsedSec = (int)currentTime;
- if (elapsedSec < 0) elapsedSec = 0;
- int totalSec = (int)state.duration;
- int remainSec = totalSec - elapsedSec;
- if (remainSec < 0) remainSec = 0;
-
- format_time((float)elapsedSec, timeBuf, sizeof(timeBuf));
- float timeFontSize = szSmall;
- float timeSpacing = timeFontSize * 0.03f;
- Vector2 leftSize = MeasureTextEx(fontSmall, timeBuf, timeFontSize, timeSpacing);
- DrawTextEx(fontSmall, timeBuf, (Vector2){ layout.barX - leftSize.x - 20, layout.barY + (layout.barHeight - timeFontSize) / 2.0f }, timeFontSize, timeSpacing, textColor);
-
- char remainBuf[16];
- format_time((float)remainSec, remainBuf, sizeof(remainBuf));
- float rightX = layout.barX + layout.barWidth + 20;
- DrawTextEx(fontSmall, remainBuf, (Vector2){ rightX, layout.barY + (layout.barHeight - timeFontSize) / 2.0f }, timeFontSize, timeSpacing, textColor);
-
- /* Percent centered above progress bar */
- char pctBuf[16];
- int pct = (int)(progress * 100.0f);
- snprintf(pctBuf, sizeof(pctBuf), "%d%%", pct);
- bool inSilence = (find_silence_at(&state, progress) >= 0);
- draw_text_centered(fontSmall, pctBuf, layout.barX + layout.barWidth / 2.0f, layout.barY - timeFontSize - 10, timeFontSize, textColor);
-
- /* Playback status */
- Color statusColor = (state.playing && inSilence) ? (Color){ 160, 40, 55, 255 } : accentColor;
- draw_text_centered(font, state.playing ? "PLAYING" : "PAUSED", layout.statusX, layout.statusY, szFont, statusColor);
-
- /* Buttons */
- Vector2 mousePos = GetMousePosition();
-
- /* Play/pause button */
- Color playBtnColor = (state.playing && inSilence) ? (Color){ 160, 40, 55, 255 } : accentColor;
- float ppx = layout.btnCenterX;
- float pdx = mousePos.x - ppx, pdy = mousePos.y - layout.btnY;
- bool hoverPP = (pdx*pdx + pdy*pdy) <= ((layout.btnRadius+5)*(layout.btnRadius+5));
- DrawCircle((int)ppx, (int)layout.btnY, layout.btnRadius + 8, playBtnColor);
- if (hoverPP) DrawCircle((int)ppx, (int)layout.btnY, layout.btnRadius + 8, btnHoverColor);
- if (state.playing)
- draw_pause_icon(ppx, layout.btnY, 50, textColor);
+ if (state.loaded)
+ ui_render_player(&ui, &state, &layout);
else
- draw_play_icon(ppx, layout.btnY, 50, textColor);
-
- /* Speaking portion counter with prev/next section buttons */
- {
- int portion = current_speaking_portion(&state, progress) + 1;
- int total = total_speaking_portions(&state);
- if (portion > total) portion = total;
- char portionBuf[32];
- snprintf(portionBuf, sizeof(portionBuf), "%d/%d", portion, total);
- float secBtnRadius = 35.0f;
- float portionY = layout.secNavY - szSmall - secBtnRadius - 10.0f;
- float portionSpacing = szSmall * 0.03f;
- Vector2 portionSize = MeasureTextEx(fontSmall, portionBuf, szSmall, portionSpacing);
- float portionX = layout.secNavX - portionSize.x / 2.0f;
- DrawTextEx(fontSmall, portionBuf, (Vector2){ portionX, portionY }, szSmall, portionSpacing, mutedColor);
-
- /* Section nav buttons */
- float secPrevX = layout.secNavX - 65.0f;
- float secNextX = layout.secNavX + 65.0f;
- float secBtnY_draw = layout.secNavY;
-
- float sd3 = mousePos.x - secPrevX, sd4 = mousePos.y - secBtnY_draw;
- bool hoverSecPrev = (sd3*sd3 + sd4*sd4) <= (secBtnRadius*secBtnRadius);
- DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 });
- if (hoverSecPrev) DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, btnHoverColor);
- draw_seek_back_icon(secPrevX, secBtnY_draw, 30, textColor);
-
- float sd5 = mousePos.x - secNextX, sd6 = mousePos.y - secBtnY_draw;
- bool hoverSecNext = (sd5*sd5 + sd6*sd6) <= (secBtnRadius*secBtnRadius);
- DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 });
- if (hoverSecNext) DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, btnHoverColor);
- draw_seek_fwd_icon(secNextX, secBtnY_draw, 30, textColor);
- }
-
- /* --- Smart play hold button rendering --- */
- {
- Rectangle smartBtn = { layout.smartPlayX, layout.smartPlayY, 200.0f, 80.0f };
- Color btnFill = smartPlayHeld ? (Color){ 80, 30, 50, 220 } : (Color){ 50, 50, 70, 180 };
- Color btnBorder = smartPlayHeld ? accentColor : mutedColor;
- Color btnTextColor = smartPlayHeld ? accentColor : textColor;
- DrawRectangleRounded(smartBtn, 0.3f, 8, btnFill);
- DrawRectangleRoundedLines(smartBtn, 0.3f, 8, btnBorder);
- float btnSpacing = szSmall * 0.03f;
- Vector2 btnSize = MeasureTextEx(fontSmall, "Play", szSmall, btnSpacing);
- float tx = smartBtn.x + (smartBtn.width - btnSize.x) / 2.0f;
- float ty = smartBtn.y + (smartBtn.height - szSmall) / 2.0f;
- DrawTextEx(fontSmall, "Play", (Vector2){ tx, ty }, szSmall, btnSpacing, btnTextColor);
- }
- }
- else
- {
- draw_text_centered(fontLarge, "Study Player", layout.titleX, layout.titleY, szLarge, textColor);
-#ifdef PLATFORM_WEB
- draw_text_centered(fontMed, "Use Load MP3 button above", (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, szMed, mutedColor);
-#else
- draw_text_centered(fontMed, "Drag an MP3 file here", (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, szMed, mutedColor);
-#endif
- }
-
- /* Help text and Study mode checkbox */
- {
- float helpSpacing = szHelp * 0.03f;
- DrawTextEx(fontHelp, "C: pause N: play Space(hold): override V/B: prev/next Arrows: seek 0-9: jump",
- (Vector2){ layout.helpX, layout.helpY }, szHelp, helpSpacing, mutedColor);
-
- const char *label = "Study Mode";
- float cbSize = 30.0f;
- Vector2 labelSize = MeasureTextEx(fontHelp, label, szHelp, helpSpacing);
- float totalW = cbSize + 10 + labelSize.x;
- float cbX = SCREEN_W - totalW - layout.helpX;
- float cbY = layout.helpY + (szHelp - cbSize) / 2.0f;
-
- Rectangle cbRect = { cbX, cbY, cbSize, cbSize };
- DrawRectangleLinesEx(cbRect, 2, mutedColor);
- if (state.studyMode)
- DrawRectangleRec((Rectangle){ cbX + 6, cbY + 6, cbSize - 12, cbSize - 12 }, accentColor);
- DrawTextEx(fontHelp, label, (Vector2){ cbX + cbSize + 10, layout.helpY }, szHelp, helpSpacing, mutedColor);
-
- if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
- {
- Vector2 mouse = GetMousePosition();
- if (mouse.x >= cbX && mouse.x <= cbX + totalW &&
- mouse.y >= cbY && mouse.y <= cbY + cbSize)
- {
- state.studyMode = !state.studyMode;
- }
- }
- }
-
+ ui_render_empty(&ui, &layout);
} else {
layout_editor_draw(exeDir, &layout);
}
@@ -754,48 +115,23 @@ static void update_frame(void)
EndDrawing();
}
+/* ------------------------------------------------------------------ */
+/* Entry point */
+/* ------------------------------------------------------------------ */
+
int main(void)
{
InitWindow(SCREEN_W, SCREEN_H, "Study Player");
InitAudioDevice();
SetTargetFPS(60);
- /* Load fonts */
-#if FONT_EMBEDDED
- fontSmall = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 60, NULL, 0);
- font = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 80, NULL, 0);
- fontMed = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 100, NULL, 0);
- fontLarge = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 160, NULL, 0);
- fontHelp = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 40, NULL, 0);
- szSmall = 60.0f;
- szHelp = 40.0f;
- szFont = 80.0f;
- szMed = 100.0f;
- szLarge = 160.0f;
-#else
- fontSmall = GetFontDefault();
- font = GetFontDefault();
- fontMed = GetFontDefault();
- fontLarge = GetFontDefault();
- fontHelp = GetFontDefault();
- szSmall = 30.0f;
- szHelp = 20.0f;
- szFont = 40.0f;
- szMed = 50.0f;
- szLarge = 80.0f;
-#endif
-
- bgColor = (Color){ 26, 26, 46, 255 };
- textColor = (Color){ 234, 234, 234, 255 };
- accentColor = (Color){ 233, 69, 96, 255 };
- mutedColor = (Color){ 140, 140, 160, 255 };
- barBgColor = (Color){ 60, 60, 60, 255 };
- btnHoverColor = (Color){ 255, 255, 255, 40 };
+ ui_init(&ui);
memset(&state, 0, sizeof(state));
state.studyMode = true;
state.lastSilenceIdx = -1;
+ /* Determine executable directory (for config load/save) */
{
char exePath[512] = {0};
#ifdef PLATFORM_LINUX
@@ -803,17 +139,15 @@ int main(void)
#endif
config_load(exePath, &layout);
- {
- const char *lastSlash = strrchr(exePath, '/');
- if (lastSlash) {
- size_t len = (size_t)(lastSlash - exePath);
- if (len >= sizeof(exeDir)) len = sizeof(exeDir) - 1;
- memcpy(exeDir, exePath, len);
- exeDir[len] = '\0';
- } else {
- exeDir[0] = '.';
- exeDir[1] = '\0';
- }
+ const char *lastSlash = strrchr(exePath, '/');
+ if (lastSlash) {
+ size_t len = (size_t)(lastSlash - exePath);
+ if (len >= sizeof(exeDir)) len = sizeof(exeDir) - 1;
+ memcpy(exeDir, exePath, len);
+ exeDir[len] = '\0';
+ } else {
+ exeDir[0] = '.';
+ exeDir[1] = '\0';
}
layout_editor_init();
@@ -823,24 +157,11 @@ int main(void)
emscripten_set_main_loop(update_frame, 0, 1);
#else
while (!WindowShouldClose())
- {
update_frame();
- }
#endif
- if (state.loaded)
- {
- StopMusicStream(state.music);
- UnloadMusicStream(state.music);
- }
-
-#if FONT_EMBEDDED
- UnloadFont(fontSmall);
- UnloadFont(font);
- UnloadFont(fontMed);
- UnloadFont(fontLarge);
- UnloadFont(fontHelp);
-#endif
+ player_unload(&state);
+ ui_destroy(&ui);
CloseAudioDevice();
CloseWindow();
diff --git a/src/player.c b/src/player.c
new file mode 100644
index 0000000..c1a7fff
--- /dev/null
+++ b/src/player.c
@@ -0,0 +1,115 @@
+#include "player.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <string.h>
+
+/* ------------------------------------------------------------------ */
+/* Helpers (module-private) */
+/* ------------------------------------------------------------------ */
+
+static int ext_equals_nocase(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;
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API */
+/* ------------------------------------------------------------------ */
+
+bool player_load(PlayerState *state, const char *path)
+{
+ const char *ext = GetFileExtension(path);
+ if (ext == NULL || ext_equals_nocase(ext, ".mp3") != 0) return false;
+
+ 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);
+ return true;
+}
+
+void player_unload(PlayerState *state)
+{
+ if (!state->loaded) return;
+ StopMusicStream(state->music);
+ UnloadMusicStream(state->music);
+ state->loaded = false;
+ state->playing = false;
+}
+
+void player_play(PlayerState *state)
+{
+ if (!state->loaded || state->playing) return;
+ ResumeMusicStream(state->music);
+ state->playing = true;
+}
+
+void player_pause(PlayerState *state)
+{
+ if (!state->loaded || !state->playing) return;
+ PauseMusicStream(state->music);
+ state->playing = false;
+}
+
+void player_seek(PlayerState *state, float target)
+{
+ if (!state->loaded) return;
+ if (target < 0.0f) target = 0.0f;
+ if (target > state->duration) target = state->duration;
+ SeekMusicStream(state->music, target);
+ state->currentTime = target;
+ state->skipAutoUpdate = 3; /* skip a few frames to let audio engine catch up */
+}
+
+void player_update(PlayerState *state)
+{
+ if (!state->loaded) return;
+ UpdateMusicStream(state->music);
+ if (state->playing) {
+ if (state->skipAutoUpdate > 0) {
+ state->skipAutoUpdate--;
+ } else {
+ state->currentTime = GetMusicTimePlayed(state->music);
+ }
+ }
+}
+
+void player_format_time(float seconds, char *buf, int bufsize)
+{
+ int total = (int)seconds;
+ if (total < 0) total = 0;
+ int h = total / 3600;
+ int m = (total % 3600) / 60;
+ int s = total % 60;
+ if (h > 0)
+ snprintf(buf, bufsize, "%d:%02d:%02d", h, m, s);
+ else
+ snprintf(buf, bufsize, "%d:%02d", m, s);
+}
diff --git a/src/player.h b/src/player.h
new file mode 100644
index 0000000..5ff8d7c
--- /dev/null
+++ b/src/player.h
@@ -0,0 +1,30 @@
+#pragma once
+
+/* player.h — audio playback contract.
+ *
+ * Owns: loading MP3 files, play/pause/seek, time formatting,
+ * music-stream updates, cleanup. */
+
+#include "types.h"
+
+/* Load an MP3 file into the player. Handles unloading any previously
+ * loaded stream. Sets state->loaded, state->playing, state->duration,
+ * state->filename. Returns true on success. */
+bool player_load(PlayerState *state, const char *path);
+
+/* Unload the current audio stream and reset loaded/playing flags. */
+void player_unload(PlayerState *state);
+
+/* Playback control. No-ops if nothing is loaded. */
+void player_play(PlayerState *state);
+void player_pause(PlayerState *state);
+
+/* Seek to an absolute time (seconds), clamped to [0, duration]. */
+void player_seek(PlayerState *state, float target);
+
+/* Per-frame update: pumps the music stream and refreshes currentTime.
+ * Call once per frame while state->loaded is true. */
+void player_update(PlayerState *state);
+
+/* Format a time in seconds as "H:MM:SS" or "M:SS" into buf. */
+void player_format_time(float seconds, char *buf, int bufsize);
diff --git a/src/study.c b/src/study.c
new file mode 100644
index 0000000..7f402bd
--- /dev/null
+++ b/src/study.c
@@ -0,0 +1,196 @@
+#include "study.h"
+
+#include <stdio.h>
+
+/* ------------------------------------------------------------------ */
+/* Silence detection */
+/* ------------------------------------------------------------------ */
+
+void study_detect_silence(const char *path, PlayerState *state,
+ float threshold, float minDuration)
+{
+ state->silenceCount = 0;
+ Wave wave = LoadWave(path);
+ if (wave.data == NULL || wave.frameCount == 0) return;
+
+ /* Convert to 32-bit float mono for easy analysis */
+ WaveFormat(&wave, wave.sampleRate, 32, 1);
+ float *samples = (float *)wave.data;
+ unsigned int totalFrames = wave.frameCount;
+ float sampleRate = (float)wave.sampleRate;
+
+ /* Scan in chunks of ~10ms */
+ int chunkSize = (int)(sampleRate * 0.01f);
+ if (chunkSize < 1) chunkSize = 1;
+ float minFrames = minDuration * sampleRate;
+
+ bool inSilence = false;
+ unsigned int silenceStart = 0;
+
+ for (unsigned int i = 0; i < totalFrames; i += chunkSize)
+ {
+ unsigned int end = i + chunkSize;
+ if (end > totalFrames) end = totalFrames;
+
+ /* Find peak amplitude in this chunk */
+ float peak = 0.0f;
+ for (unsigned int j = i; j < end; j++)
+ {
+ float v = samples[j];
+ if (v < 0) v = -v;
+ if (v > peak) peak = v;
+ }
+
+ if (peak < threshold)
+ {
+ if (!inSilence) { silenceStart = i; inSilence = true; }
+ }
+ else
+ {
+ if (inSilence)
+ {
+ unsigned int len = i - silenceStart;
+ if ((float)len >= minFrames && state->silenceCount < MAX_SILENCE_REGIONS)
+ {
+ state->silence[state->silenceCount].start = (float)silenceStart / (float)totalFrames;
+ state->silence[state->silenceCount].end = (float)i / (float)totalFrames;
+ state->silenceCount++;
+ }
+ inSilence = false;
+ }
+ }
+ }
+ /* Close any trailing silence */
+ if (inSilence)
+ {
+ unsigned int len = totalFrames - silenceStart;
+ if ((float)len >= minFrames && state->silenceCount < MAX_SILENCE_REGIONS)
+ {
+ state->silence[state->silenceCount].start = (float)silenceStart / (float)totalFrames;
+ state->silence[state->silenceCount].end = (float)totalFrames / (float)totalFrames;
+ state->silenceCount++;
+ }
+ }
+
+ UnloadWave(wave);
+
+ /* Pad speaking portions by shrinking silence regions 0.25s on each side */
+ if (state->duration > 0.0f)
+ {
+ float padNorm = 0.25f / state->duration;
+ for (int i = 0; i < state->silenceCount; i++)
+ {
+ state->silence[i].start += padNorm;
+ state->silence[i].end -= padNorm;
+ if (state->silence[i].start >= state->silence[i].end)
+ {
+ /* Region too small after padding, remove it */
+ for (int j = i; j < state->silenceCount - 1; j++)
+ state->silence[j] = state->silence[j + 1];
+ state->silenceCount--;
+ i--;
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Portion navigation */
+/* ------------------------------------------------------------------ */
+
+int study_find_silence_at(const PlayerState *state, float pos)
+{
+ for (int i = 0; i < state->silenceCount; i++)
+ if (pos >= state->silence[i].start && pos < state->silence[i].end) return i;
+ return -1;
+}
+
+float study_speaking_portion_start(const PlayerState *state, int portion)
+{
+ if (portion <= 0) return 0.0f;
+ if (portion > state->silenceCount) return state->silence[state->silenceCount - 1].end;
+ return state->silence[portion - 1].end;
+}
+
+int study_current_speaking_portion(const PlayerState *state, float pos)
+{
+ int portion = 0;
+ for (int i = 0; i < state->silenceCount; i++)
+ {
+ if (pos >= state->silence[i].end)
+ portion = i + 1;
+ else
+ break;
+ }
+ return portion;
+}
+
+int study_total_speaking_portions(const PlayerState *state)
+{
+ return state->silenceCount + 1;
+}
+
+float study_segment_seek_target(const PlayerState *state, int portion)
+{
+ float pos = study_speaking_portion_start(state, portion);
+ float target = pos * state->duration + (2.0f / 60.0f);
+ if (target < 0.0f) target = 0.0f;
+ if (target > state->duration) target = state->duration;
+ return target;
+}
+
+bool study_in_padding_zone(const PlayerState *state, float pos, int portion)
+{
+ if (state->duration <= 0.0f) return false;
+ float padNorm = 0.25f / state->duration;
+ float start = study_speaking_portion_start(state, portion);
+ return (pos >= start && pos < start + padNorm);
+}
+
+/* ------------------------------------------------------------------ */
+/* Auto-pause logic */
+/* ------------------------------------------------------------------ */
+
+void study_auto_pause_check(PlayerState *state, bool smartPlayHeld, bool spaceHeld)
+{
+ if (!state->studyMode || state->duration <= 0.0f || !state->playing)
+ return;
+
+ if (smartPlayHeld || spaceHeld) {
+ /* Override: still track silence state but don't auto-pause */
+ float pos = state->currentTime / state->duration;
+ int silIdx = study_find_silence_at(state, pos);
+ state->wasInSilence = (silIdx >= 0);
+ if (silIdx >= 0) state->lastSilenceIdx = silIdx;
+ return;
+ }
+
+ float pos = state->currentTime / state->duration;
+ int silIdx = study_find_silence_at(state, pos);
+ bool nowInSilence = (silIdx >= 0);
+
+ if (nowInSilence && !state->wasInSilence)
+ {
+ /* Entering silence: jump to start of next speaking portion */
+ float target = study_segment_seek_target(state, silIdx + 1);
+ PauseMusicStream(state->music);
+ SeekMusicStream(state->music, target);
+ state->currentTime = target;
+ state->playing = false;
+ state->skipAutoUpdate = 3;
+ }
+ else if (!nowInSilence && state->wasInSilence)
+ {
+ /* Exiting silence: land at start of current speaking portion */
+ int portion = study_current_speaking_portion(state, pos);
+ float target = study_segment_seek_target(state, portion);
+ PauseMusicStream(state->music);
+ SeekMusicStream(state->music, target);
+ state->currentTime = target;
+ state->playing = false;
+ state->skipAutoUpdate = 3;
+ }
+
+ state->wasInSilence = nowInSilence;
+ if (nowInSilence) state->lastSilenceIdx = silIdx;
+}
diff --git a/src/study.h b/src/study.h
new file mode 100644
index 0000000..0b7b030
--- /dev/null
+++ b/src/study.h
@@ -0,0 +1,45 @@
+#pragma once
+
+/* study.h — study mode contract.
+ *
+ * Owns: silence detection, speaking-portion navigation, auto-pause logic.
+ * All functions take PlayerState* and work with the normalized positions
+ * stored in state->silence[]. */
+
+#include "types.h"
+
+/* Analyze an audio file and populate state->silence[] with detected gaps.
+ * threshold — amplitude below which a chunk is "silent" (e.g. 0.015).
+ * minDuration — minimum silence length in seconds (e.g. 0.75).
+ * Requires state->duration to be set (call after player_load). */
+void study_detect_silence(const char *path, PlayerState *state,
+ float threshold, float minDuration);
+
+/* Return the index of the silence region containing pos (normalized 0..1),
+ * or -1 if none. */
+int study_find_silence_at(const PlayerState *state, float pos);
+
+/* Start (normalized) of speaking portion N (0-based). Portion 0 = 0.0. */
+float study_speaking_portion_start(const PlayerState *state, int portion);
+
+/* Which speaking portion (0-based) the position falls in.
+ * During silence, returns the previous speaking portion. */
+int study_current_speaking_portion(const PlayerState *state, float pos);
+
+/* Total number of speaking portions (silenceCount + 1). */
+int study_total_speaking_portions(const PlayerState *state);
+
+/* Seek target (seconds) for jumping to a speaking portion.
+ * Lands 2 render-frames (~33ms) into the padding zone. */
+float study_segment_seek_target(const PlayerState *state, int portion);
+
+/* Is pos inside the padding zone of the given speaking portion?
+ * Padding zone = [speaking_start, speaking_start + 0.25s/duration]. */
+bool study_in_padding_zone(const PlayerState *state, float pos, int portion);
+
+/* Run the study-mode auto-pause check for this frame.
+ * Call after player_update while state->studyMode && state->playing.
+ * smartPlayHeld / spaceHeld suppress auto-pause when true.
+ * Mutates: state->playing, state->currentTime, state->wasInSilence,
+ * state->lastSilenceIdx, state->skipAutoUpdate. */
+void study_auto_pause_check(PlayerState *state, bool smartPlayHeld, bool spaceHeld);
diff --git a/src/types.h b/src/types.h
new file mode 100644
index 0000000..87ccc37
--- /dev/null
+++ b/src/types.h
@@ -0,0 +1,77 @@
+#pragma once
+
+/* types.h — shared types, constants, and defines.
+ *
+ * This is the ONE header every module includes for shared data structures.
+ * No .c file — pure declarations. Raylib is included because PlayerState
+ * holds a Music handle. */
+
+#include "raylib.h"
+
+/* ------------------------------------------------------------------ */
+/* Screen dimensions */
+/* ------------------------------------------------------------------ */
+
+#define SCREEN_W 1920
+#define SCREEN_H 1080
+
+/* ------------------------------------------------------------------ */
+/* Limits */
+/* ------------------------------------------------------------------ */
+
+#define MAX_SILENCE_REGIONS 4096
+#define MAX_FILENAME 256
+
+/* ------------------------------------------------------------------ */
+/* Audio / study types */
+/* ------------------------------------------------------------------ */
+
+/* A detected gap in the audio where amplitude stays below threshold.
+ * start/end are normalized 0..1 relative to total frame count. */
+typedef struct {
+ float start; /* normalized 0..1 */
+ float end; /* normalized 0..1 */
+} SilenceRegion;
+
+/* All mutable runtime state. Passed by pointer to every module. */
+typedef struct {
+ Music music;
+ bool loaded;
+ bool playing;
+ float duration;
+ float currentTime;
+ char filename[MAX_FILENAME];
+
+ SilenceRegion silence[MAX_SILENCE_REGIONS];
+ int silenceCount;
+
+ bool studyMode;
+ bool wasInSilence; /* for detecting silence entry */
+ int lastSilenceIdx; /* index of silence region we were last in, or -1 */
+ int skipAutoUpdate; /* frames to skip auto-updating currentTime */
+} PlayerState;
+
+/* ------------------------------------------------------------------ */
+/* UI layout — pixel positions for every on-screen element. */
+/* Persisted to study-player.cfg via config_load / config_save. */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ float titleY;
+ float titleX;
+ float barY;
+ float barHeight;
+ float barWidth;
+ float barX;
+ float statusY;
+ float statusX;
+ float btnRadius;
+ float helpY;
+ float helpX;
+ float btnY;
+ float btnCenterX;
+ float smartPlayY;
+ float smartPlayX;
+ float secNavY;
+ float secNavX;
+} UILayout;
diff --git a/src/ui.c b/src/ui.c
new file mode 100644
index 0000000..7d6a808
--- /dev/null
+++ b/src/ui.c
@@ -0,0 +1,470 @@
+#include "ui.h"
+#include "study.h"
+#include "player.h"
+#include "font_data.h"
+
+#include <stdio.h>
+
+/* ------------------------------------------------------------------ */
+/* Module-private drawing helpers */
+/* ------------------------------------------------------------------ */
+
+static void draw_text_centered(Font f, const char *text, float centerX,
+ float y, float fontSize, Color color)
+{
+ float spacing = fontSize * 0.03f;
+ Vector2 size = MeasureTextEx(f, text, fontSize, spacing);
+ float x = centerX - size.x / 2.0f;
+ DrawTextEx(f, text, (Vector2){ x, y }, fontSize, spacing, color);
+}
+
+static void draw_play_icon(float cx, float cy, float size, Color color)
+{
+ float half = size / 2.0f;
+ Vector2 v1 = { cx - half * 0.7f, cy - half };
+ Vector2 v2 = { cx - half * 0.7f, cy + half };
+ Vector2 v3 = { cx + half * 0.8f, cy };
+ DrawTriangle(v1, v2, v3, color);
+}
+
+static void draw_pause_icon(float cx, float cy, float size, Color color)
+{
+ float half = size / 2.0f;
+ float barW = size * 0.25f;
+ float gap = size * 0.15f;
+ DrawRectangleRec((Rectangle){ cx - gap - barW, cy - half, barW, size }, color);
+ DrawRectangleRec((Rectangle){ cx + gap, cy - half, barW, size }, color);
+}
+
+static void draw_seek_back_icon(float cx, float cy, float size, Color color)
+{
+ float half = size / 2.0f;
+ Vector2 v1 = { cx + half * 0.7f, cy - half };
+ Vector2 v2 = { cx - half * 0.8f, cy };
+ Vector2 v3 = { cx + half * 0.7f, cy + half };
+ DrawTriangle(v1, v2, v3, color);
+}
+
+static void draw_seek_fwd_icon(float cx, float cy, float size, Color color)
+{
+ float half = size / 2.0f;
+ Vector2 v1 = { cx - half * 0.7f, cy - half };
+ Vector2 v2 = { cx - half * 0.7f, cy + half };
+ Vector2 v3 = { cx + half * 0.8f, cy };
+ DrawTriangle(v1, v2, v3, color);
+}
+
+static bool button_hit(float cx, float cy, float radius)
+{
+ if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) return false;
+ Vector2 m = GetMousePosition();
+ float dx = m.x - cx;
+ float dy = m.y - cy;
+ return (dx * dx + dy * dy) <= (radius * radius);
+}
+
+/* ------------------------------------------------------------------ */
+/* Lifecycle */
+/* ------------------------------------------------------------------ */
+
+void ui_init(UIState *ui)
+{
+#if FONT_EMBEDDED
+ ui->fontSmall = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 60, NULL, 0);
+ ui->font = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 80, NULL, 0);
+ ui->fontMed = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 100, NULL, 0);
+ ui->fontLarge = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 160, NULL, 0);
+ ui->fontHelp = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 40, NULL, 0);
+ ui->szSmall = 60.0f;
+ ui->szHelp = 40.0f;
+ ui->szFont = 80.0f;
+ ui->szMed = 100.0f;
+ ui->szLarge = 160.0f;
+#else
+ ui->fontSmall = GetFontDefault();
+ ui->font = GetFontDefault();
+ ui->fontMed = GetFontDefault();
+ ui->fontLarge = GetFontDefault();
+ ui->fontHelp = GetFontDefault();
+ ui->szSmall = 30.0f;
+ ui->szHelp = 20.0f;
+ ui->szFont = 40.0f;
+ ui->szMed = 50.0f;
+ ui->szLarge = 80.0f;
+#endif
+
+ ui->bgColor = (Color){ 26, 26, 46, 255 };
+ ui->textColor = (Color){ 234, 234, 234, 255 };
+ ui->accentColor = (Color){ 233, 69, 96, 255 };
+ ui->mutedColor = (Color){ 140, 140, 160, 255 };
+ ui->barBgColor = (Color){ 60, 60, 60, 255 };
+ ui->btnHoverColor = (Color){ 255, 255, 255, 40 };
+
+ ui->smartPlayHeld = false;
+}
+
+void ui_destroy(UIState *ui)
+{
+#if FONT_EMBEDDED
+ UnloadFont(ui->fontSmall);
+ UnloadFont(ui->font);
+ UnloadFont(ui->fontMed);
+ UnloadFont(ui->fontLarge);
+ UnloadFont(ui->fontHelp);
+#else
+ (void)ui;
+#endif
+}
+
+/* ------------------------------------------------------------------ */
+/* Input */
+/* ------------------------------------------------------------------ */
+
+void ui_handle_input(UIState *ui, PlayerState *state, const UILayout *layout)
+{
+ if (!state->loaded) {
+ /* Study-mode checkbox is always available */
+ goto checkbox;
+ }
+
+ /* --- Play/pause button --- */
+ if (button_hit(layout->btnCenterX, layout->btnY, layout->btnRadius))
+ {
+ if (state->playing)
+ player_pause(state);
+ else
+ player_play(state);
+ }
+
+ /* --- Section nav buttons --- */
+ {
+ float progress = (state->duration > 0.0f) ? state->currentTime / state->duration : 0.0f;
+ int portion = study_current_speaking_portion(state, progress) + 1;
+ int total = study_total_speaking_portions(state);
+ if (portion > total) portion = total;
+ float secBtnRadius = 35.0f;
+ float secPrevX = layout->secNavX - 65.0f;
+ float secNextX = layout->secNavX + 65.0f;
+ float secBtnY = layout->secNavY;
+
+ if (button_hit(secPrevX, secBtnY, secBtnRadius))
+ {
+ float pos = state->currentTime / state->duration;
+ int p = study_current_speaking_portion(state, pos);
+ bool inSil = (study_find_silence_at(state, pos) >= 0);
+ bool inPad = study_in_padding_zone(state, pos, p);
+ if ((inSil || inPad) && p > 0) p--;
+ float target = study_segment_seek_target(state, p);
+ player_seek(state, target);
+ state->wasInSilence = false;
+ state->lastSilenceIdx = -1;
+ }
+ if (button_hit(secNextX, secBtnY, secBtnRadius))
+ {
+ float pos = state->currentTime / state->duration;
+ int p = study_current_speaking_portion(state, pos);
+ if (p < total - 1) p++;
+ float target = study_segment_seek_target(state, p);
+ player_seek(state, target);
+ state->wasInSilence = false;
+ state->lastSilenceIdx = -1;
+ }
+ }
+
+ /* --- Smart play hold button --- */
+ {
+ Rectangle smartBtn = { layout->smartPlayX, layout->smartPlayY, 200.0f, 80.0f };
+ Vector2 mouse = GetMousePosition();
+ bool overBtn = (mouse.x >= smartBtn.x && mouse.x <= smartBtn.x + smartBtn.width &&
+ mouse.y >= smartBtn.y && mouse.y <= smartBtn.y + smartBtn.height);
+
+ if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && overBtn) {
+ ui->smartPlayHeld = true;
+ if (!state->playing) {
+ ResumeMusicStream(state->music);
+ state->playing = true;
+ }
+ }
+
+ if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && ui->smartPlayHeld) {
+ /* Allow finger drift - stay held */
+ }
+
+ if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) {
+ ui->smartPlayHeld = false;
+ }
+
+ if (GetTouchPointCount() > 0 && ui->smartPlayHeld) {
+ /* Touch fallback: keep held while touch points active */
+ }
+ }
+
+ /* --- Keyboard input --- */
+ if (IsKeyPressed(KEY_C) && state->playing)
+ player_pause(state);
+
+ if (IsKeyPressed(KEY_N) && !state->playing)
+ {
+ float pos = state->currentTime / state->duration;
+ int portion = study_current_speaking_portion(state, pos);
+ float target = study_segment_seek_target(state, portion);
+ player_seek(state, target);
+ player_play(state);
+ }
+
+ if (IsKeyPressed(KEY_SPACE) && !state->playing)
+ player_play(state);
+
+ if (IsKeyPressed(KEY_V))
+ {
+ float pos = state->currentTime / state->duration;
+ int portion = study_current_speaking_portion(state, pos);
+ bool inSil = (study_find_silence_at(state, pos) >= 0);
+ bool inPad = study_in_padding_zone(state, pos, portion);
+ if ((inSil || inPad) && portion > 0)
+ portion--;
+ float target = study_segment_seek_target(state, portion);
+ player_seek(state, target);
+ state->wasInSilence = false;
+ state->lastSilenceIdx = -1;
+ }
+
+ if (IsKeyPressed(KEY_B))
+ {
+ float pos = state->currentTime / state->duration;
+ int portion = study_current_speaking_portion(state, pos);
+ int total = study_total_speaking_portions(state);
+ if (portion < total - 1) portion++;
+ float target = study_segment_seek_target(state, portion);
+ player_seek(state, target);
+ state->wasInSilence = false;
+ state->lastSilenceIdx = -1;
+ }
+
+ /* Click-to-seek on progress bar */
+ if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
+ {
+ Vector2 mouse = GetMousePosition();
+ if (mouse.x >= layout->barX && mouse.x <= layout->barX + layout->barWidth &&
+ mouse.y >= layout->barY && mouse.y <= layout->barY + layout->barHeight)
+ {
+ float target = ((mouse.x - layout->barX) / layout->barWidth) * state->duration;
+ player_seek(state, target);
+ }
+ }
+
+ /* Arrow key seeking */
+ if (IsKeyPressed(KEY_LEFT))
+ player_seek(state, state->currentTime - 5.0f);
+ if (IsKeyPressed(KEY_RIGHT))
+ player_seek(state, state->currentTime + 5.0f);
+ if (IsKeyPressed(KEY_UP) && !state->playing)
+ player_play(state);
+ if (IsKeyPressed(KEY_DOWN) && state->playing)
+ {
+ player_pause(state);
+ float rewind = state->currentTime - 1.0f;
+ if (rewind < 0.0f) rewind = 0.0f;
+ SeekMusicStream(state->music, rewind);
+ state->currentTime = rewind;
+ }
+
+ /* Number key seeking (0-9 = 0%-90%) */
+ for (int k = 0; k <= 9; k++)
+ {
+ if (IsKeyPressed(KEY_ZERO + k))
+ {
+ float target = state->duration * (k / 10.0f);
+ player_seek(state, target);
+ break;
+ }
+ }
+
+checkbox:
+ /* --- Study mode checkbox --- */
+ {
+ float helpSpacing = ui->szHelp * 0.03f;
+ const char *label = "Study Mode";
+ float cbSize = 30.0f;
+ Vector2 labelSize = MeasureTextEx(ui->fontHelp, label, ui->szHelp, helpSpacing);
+ float totalW = cbSize + 10 + labelSize.x;
+ float cbX = SCREEN_W - totalW - layout->helpX;
+ float cbY = layout->helpY + (ui->szHelp - cbSize) / 2.0f;
+
+ if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
+ {
+ Vector2 mouse = GetMousePosition();
+ if (mouse.x >= cbX && mouse.x <= cbX + totalW &&
+ mouse.y >= cbY && mouse.y <= cbY + cbSize)
+ {
+ state->studyMode = !state->studyMode;
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Rendering */
+/* ------------------------------------------------------------------ */
+
+void ui_render_player(const UIState *ui, const PlayerState *state,
+ const UILayout *layout)
+{
+ draw_text_centered(ui->font, state->filename, layout->titleX, layout->titleY,
+ ui->szFont, ui->mutedColor);
+
+ /* Progress bar */
+ float progress = (state->duration > 0.0f) ? state->currentTime / state->duration : 0.0f;
+ if (progress > 1.0f) progress = 1.0f;
+
+ Rectangle barBg = { layout->barX, layout->barY, layout->barWidth, layout->barHeight };
+ Rectangle barFill = { layout->barX, layout->barY, layout->barWidth * progress, layout->barHeight };
+ DrawRectangleRounded(barBg, 0.4f, 8, ui->barBgColor);
+ if (progress > 0.001f)
+ DrawRectangleRounded(barFill, 0.4f, 8, ui->accentColor);
+
+ /* Time labels */
+ char timeBuf[16];
+ int elapsedSec = (int)state->currentTime;
+ if (elapsedSec < 0) elapsedSec = 0;
+ int totalSec = (int)state->duration;
+ int remainSec = totalSec - elapsedSec;
+ if (remainSec < 0) remainSec = 0;
+
+ player_format_time((float)elapsedSec, timeBuf, sizeof(timeBuf));
+ float timeFontSize = ui->szSmall;
+ float timeSpacing = timeFontSize * 0.03f;
+ Vector2 leftSize = MeasureTextEx(ui->fontSmall, timeBuf, timeFontSize, timeSpacing);
+ DrawTextEx(ui->fontSmall, timeBuf,
+ (Vector2){ layout->barX - leftSize.x - 20,
+ layout->barY + (layout->barHeight - timeFontSize) / 2.0f },
+ timeFontSize, timeSpacing, ui->textColor);
+
+ char remainBuf[16];
+ player_format_time((float)remainSec, remainBuf, sizeof(remainBuf));
+ float rightX = layout->barX + layout->barWidth + 20;
+ DrawTextEx(ui->fontSmall, remainBuf,
+ (Vector2){ rightX, layout->barY + (layout->barHeight - timeFontSize) / 2.0f },
+ timeFontSize, timeSpacing, ui->textColor);
+
+ /* Percent centered above progress bar */
+ char pctBuf[16];
+ int pct = (int)(progress * 100.0f);
+ snprintf(pctBuf, sizeof(pctBuf), "%d%%", pct);
+ bool inSilence = (study_find_silence_at(state, progress) >= 0);
+ draw_text_centered(ui->fontSmall, pctBuf,
+ layout->barX + layout->barWidth / 2.0f,
+ layout->barY - timeFontSize - 10, timeFontSize, ui->textColor);
+
+ /* Playback status */
+ Color statusColor = (state->playing && inSilence)
+ ? (Color){ 160, 40, 55, 255 } : ui->accentColor;
+ draw_text_centered(ui->font, state->playing ? "PLAYING" : "PAUSED",
+ layout->statusX, layout->statusY, ui->szFont, statusColor);
+
+ /* Buttons */
+ Vector2 mousePos = GetMousePosition();
+
+ /* Play/pause button */
+ Color playBtnColor = (state->playing && inSilence)
+ ? (Color){ 160, 40, 55, 255 } : ui->accentColor;
+ float ppx = layout->btnCenterX;
+ float pdx = mousePos.x - ppx, pdy = mousePos.y - layout->btnY;
+ bool hoverPP = (pdx*pdx + pdy*pdy) <= ((layout->btnRadius+5)*(layout->btnRadius+5));
+ DrawCircle((int)ppx, (int)layout->btnY, layout->btnRadius + 8, playBtnColor);
+ if (hoverPP) DrawCircle((int)ppx, (int)layout->btnY, layout->btnRadius + 8, ui->btnHoverColor);
+ if (state->playing)
+ draw_pause_icon(ppx, layout->btnY, 50, ui->textColor);
+ else
+ draw_play_icon(ppx, layout->btnY, 50, ui->textColor);
+
+ /* Speaking portion counter with prev/next section buttons */
+ {
+ int portion = study_current_speaking_portion(state, progress) + 1;
+ int total = study_total_speaking_portions(state);
+ if (portion > total) portion = total;
+ char portionBuf[32];
+ snprintf(portionBuf, sizeof(portionBuf), "%d/%d", portion, total);
+ float secBtnRadius = 35.0f;
+ float portionY = layout->secNavY - ui->szSmall - secBtnRadius - 10.0f;
+ float portionSpacing = ui->szSmall * 0.03f;
+ Vector2 portionSize = MeasureTextEx(ui->fontSmall, portionBuf, ui->szSmall, portionSpacing);
+ float portionX = layout->secNavX - portionSize.x / 2.0f;
+ DrawTextEx(ui->fontSmall, portionBuf,
+ (Vector2){ portionX, portionY },
+ ui->szSmall, portionSpacing, ui->mutedColor);
+
+ /* Section nav buttons */
+ float secPrevX = layout->secNavX - 65.0f;
+ float secNextX = layout->secNavX + 65.0f;
+ float secBtnY_draw = layout->secNavY;
+
+ float sd3 = mousePos.x - secPrevX, sd4 = mousePos.y - secBtnY_draw;
+ bool hoverSecPrev = (sd3*sd3 + sd4*sd4) <= (secBtnRadius*secBtnRadius);
+ DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 });
+ if (hoverSecPrev) DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, ui->btnHoverColor);
+ draw_seek_back_icon(secPrevX, secBtnY_draw, 30, ui->textColor);
+
+ float sd5 = mousePos.x - secNextX, sd6 = mousePos.y - secBtnY_draw;
+ bool hoverSecNext = (sd5*sd5 + sd6*sd6) <= (secBtnRadius*secBtnRadius);
+ DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 });
+ if (hoverSecNext) DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, ui->btnHoverColor);
+ draw_seek_fwd_icon(secNextX, secBtnY_draw, 30, ui->textColor);
+ }
+
+ /* --- Smart play hold button rendering --- */
+ {
+ Rectangle smartBtn = { layout->smartPlayX, layout->smartPlayY, 200.0f, 80.0f };
+ Color btnFill = ui->smartPlayHeld ? (Color){ 80, 30, 50, 220 } : (Color){ 50, 50, 70, 180 };
+ Color btnBorder = ui->smartPlayHeld ? ui->accentColor : ui->mutedColor;
+ Color btnTextColor = ui->smartPlayHeld ? ui->accentColor : ui->textColor;
+ DrawRectangleRounded(smartBtn, 0.3f, 8, btnFill);
+ DrawRectangleRoundedLines(smartBtn, 0.3f, 8, btnBorder);
+ float btnSpacing = ui->szSmall * 0.03f;
+ Vector2 btnSize = MeasureTextEx(ui->fontSmall, "Play", ui->szSmall, btnSpacing);
+ float tx = smartBtn.x + (smartBtn.width - btnSize.x) / 2.0f;
+ float ty = smartBtn.y + (smartBtn.height - ui->szSmall) / 2.0f;
+ DrawTextEx(ui->fontSmall, "Play", (Vector2){ tx, ty }, ui->szSmall, btnSpacing, btnTextColor);
+ }
+
+ /* --- Help text --- */
+ {
+ float helpSpacing = ui->szHelp * 0.03f;
+ DrawTextEx(ui->fontHelp,
+ "C: pause N: play Space(hold): override V/B: prev/next Arrows: seek 0-9: jump",
+ (Vector2){ layout->helpX, layout->helpY },
+ ui->szHelp, helpSpacing, ui->mutedColor);
+ }
+
+ /* --- Study mode checkbox --- */
+ {
+ float helpSpacing = ui->szHelp * 0.03f;
+ const char *label = "Study Mode";
+ float cbSize = 30.0f;
+ Vector2 labelSize = MeasureTextEx(ui->fontHelp, label, ui->szHelp, helpSpacing);
+ float totalW = cbSize + 10 + labelSize.x;
+ float cbX = SCREEN_W - totalW - layout->helpX;
+ float cbY = layout->helpY + (ui->szHelp - cbSize) / 2.0f;
+
+ Rectangle cbRect = { cbX, cbY, cbSize, cbSize };
+ DrawRectangleLinesEx(cbRect, 2, ui->mutedColor);
+ if (state->studyMode)
+ DrawRectangleRec((Rectangle){ cbX + 6, cbY + 6, cbSize - 12, cbSize - 12 }, ui->accentColor);
+ DrawTextEx(ui->fontHelp, label,
+ (Vector2){ cbX + cbSize + 10, layout->helpY },
+ ui->szHelp, helpSpacing, ui->mutedColor);
+ }
+}
+
+void ui_render_empty(const UIState *ui, const UILayout *layout)
+{
+ draw_text_centered(ui->fontLarge, "Study Player",
+ layout->titleX, layout->titleY, ui->szLarge, ui->textColor);
+#ifdef PLATFORM_WEB
+ draw_text_centered(ui->fontMed, "Use Load MP3 button above",
+ (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, ui->szMed, ui->mutedColor);
+#else
+ draw_text_centered(ui->fontMed, "Drag an MP3 file here",
+ (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, ui->szMed, ui->mutedColor);
+#endif
+}
diff --git a/src/ui.h b/src/ui.h
new file mode 100644
index 0000000..10b4240
--- /dev/null
+++ b/src/ui.h
@@ -0,0 +1,62 @@
+#pragma once
+
+/* ui.h — rendering and input contract.
+ *
+ * Owns: font/color initialization, all drawing, all mouse/keyboard input
+ * for the player tab, the smart-play hold button, study-mode checkbox.
+ * Does NOT own: tab bar (drawn by main via raygui), layout editor tab,
+ * drag-drop file loading (main), config save on tab switch (main). */
+
+#include "types.h"
+#include "config.h"
+
+/* ------------------------------------------------------------------ */
+/* UI state — fonts, colors, interaction flags. */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ /* Fonts (loaded in ui_init, unloaded in ui_destroy) */
+ Font fontSmall, font, fontMed, fontLarge, fontHelp;
+ float szSmall, szHelp, szFont, szMed, szLarge;
+
+ /* Colors */
+ Color bgColor;
+ Color textColor;
+ Color accentColor;
+ Color mutedColor;
+ Color barBgColor;
+ Color btnHoverColor;
+
+ /* Interaction state */
+ bool smartPlayHeld;
+} UIState;
+
+/* ------------------------------------------------------------------ */
+/* Lifecycle */
+/* ------------------------------------------------------------------ */
+
+/* Load fonts (embedded or default) and set colors. Call after InitWindow. */
+void ui_init(UIState *ui);
+
+/* Unload fonts (embedded only). Call before CloseWindow. */
+void ui_destroy(UIState *ui);
+
+/* ------------------------------------------------------------------ */
+/* Input — call once per frame BEFORE player_update, only on tab 0. */
+/* Processes button clicks, keyboard, click-to-seek, smart-play hold, */
+/* study-mode checkbox toggle. Mutates state and ui->smartPlayHeld. */
+/* ------------------------------------------------------------------ */
+
+void ui_handle_input(UIState *ui, PlayerState *state, const UILayout *layout);
+
+/* ------------------------------------------------------------------ */
+/* Rendering — call inside BeginDrawing/EndDrawing, only on tab 0. */
+/* ------------------------------------------------------------------ */
+
+/* Draw the player UI (filename, progress bar, buttons, help, checkbox).
+ * Call when state->loaded is true. */
+void ui_render_player(const UIState *ui, const PlayerState *state,
+ const UILayout *layout);
+
+/* Draw the "no file loaded" splash screen. */
+void ui_render_empty(const UIState *ui, const UILayout *layout);
diff --git a/tasks.md b/tasks.md
index 14c6e80..bb351bd 100644
--- a/tasks.md
+++ b/tasks.md
@@ -4,40 +4,41 @@
---
-## WAVE 0 — Orchestrator + build system agent
-
-- [ ] Orchestrator: write `src/types.h` (shared types, constants)
-- [ ] Orchestrator: pre-author `src/player.h` (audio playback contract)
-- [ ] Orchestrator: pre-author `src/study.h` (study mode contract)
-- [ ] Orchestrator: pre-author `src/ui.h` (rendering + input contract)
-- [ ] Orchestrator: write TASK prompts in `prompts/`
-- [ ] Build agent: update `Makefile` (Linux native + Windows cross-compile targets)
-- [ ] Build agent: update `.gitignore` (add `prompts/`, `reports/`)
-
-## WAVE 1 — All `.c` implementations (parallel)
-
-- [ ] Agent A: implement `src/player.c` from `player.h` contract
-- [ ] Agent B: implement `src/study.c` from `study.h` contract
-- [ ] Agent C: implement `src/ui.c` from `ui.h` contract
-- [ ] Agent D: implement `src/main.c` (composition root)
-
-## WAVE 2 — Integration fixes (if needed)
-
-- [ ] Fix any link errors or behavioral regressions
-
-## Post-milestone
-
-- [ ] `make clean && make -j$(nproc)` exits 0, zero warnings (Linux)
-- [ ] `make windows -j$(nproc)` exits 0, zero warnings (Windows cross-compile)
-- [ ] Functional test: play an MP3, test all keyboard shortcuts, study mode, UI
-- [ ] Commit: `refactor: split src/main.c into modular .h/.c files`
+## Module split (COMPLETE)
+
+The single-file `src/main.c` (778 lines) has been decomposed into 7 modules
+with separate headers for code isolation and parallel agent development.
+
+- [x] WAVE 0 — Orchestrator: pre-author all `.h` contracts
+ - [x] `src/types.h` — PlayerState, SilenceRegion, UILayout, constants
+ - [x] `src/player.h` — audio playback contract
+ - [x] `src/study.h` — study mode contract
+ - [x] `src/ui.h` — rendering + input contract (UIState)
+ - [x] `src/config.h` — updated to include types.h (UILayout moved)
+- [x] WAVE 1 — All `.c` implementations
+ - [x] `src/player.c` — load/play/pause/seek/update/format_time
+ - [x] `src/study.c` — detect_silence, portion nav, auto-pause check
+ - [x] `src/ui.c` — fonts, colors, drawing, icons, input, smart play
+ - [x] `src/main.c` — thin composition root (main loop, tabs, drag-drop)
+- [x] WAVE 2 — Integration fixes
+ - [x] Fixed multiple-definition link error (font_data.h single-include rule)
+- [x] Post-milestone verification
+ - [x] `make clean && make -j$(nproc)` exits 0, zero warnings (Linux)
+ - [x] Makefile auto-discovers new `.c` files via `$(wildcard src/*.c)`
+ - [x] All state passed by pointer — no global mutable variables
+ - [x] `font_data.h` included in exactly one `.c` file (ui.c)
+
+## Branch structure
+
+- **V1** branch — preserves the pre-refactor state (single-file main.c)
+- **dev** branch — the refactored modular codebase (current)
---
## Open items (future)
-- [ ] `bin/build` and `bin/build-web` scripts can be retired or simplified
-- [ ] Test coverage (unit tests for study module?)
+- [ ] `make windows -j$(nproc)` cross-compile verification (needs MinGW)
+- [ ] `bin/build-web` web build verification (needs emscripten)
+- [ ] Unit tests for study module (portion navigation edge cases)
- [ ] Volume control
- [ ] Playlist support
-- [ ] Config file / persistence