diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 22:23:59 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 22:23:59 +0900 |
| commit | 6bf8907cd69383e8bb0a77b741d314bddf8008e6 (patch) | |
| tree | 5a3786664e41045eb3ff962dc055e0f25af1ac4b | |
| parent | 831747ddb5886364707b696ac38cce8da333f15d (diff) | |
| download | study-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.md | 9 | ||||
| -rw-r--r-- | .gitignore | 18 | ||||
| -rw-r--r-- | AGENTS.md | 13 | ||||
| -rw-r--r-- | GLOSSARY.md | 21 | ||||
| -rw-r--r-- | ORCHESTRATOR.md | 64 | ||||
| -rw-r--r-- | README.md | 53 | ||||
| -rwxr-xr-x | bin/build | 41 | ||||
| -rwxr-xr-x | bin/build-web | 85 | ||||
| -rwxr-xr-x | bin/clean | 14 | ||||
| -rwxr-xr-x | bin/serve | 14 | ||||
| -rw-r--r-- | src/config.h | 29 | ||||
| -rw-r--r-- | src/main.c | 801 | ||||
| -rw-r--r-- | src/player.c | 115 | ||||
| -rw-r--r-- | src/player.h | 30 | ||||
| -rw-r--r-- | src/study.c | 196 | ||||
| -rw-r--r-- | src/study.h | 45 | ||||
| -rw-r--r-- | src/types.h | 77 | ||||
| -rw-r--r-- | src/ui.c | 470 | ||||
| -rw-r--r-- | src/ui.h | 62 | ||||
| -rw-r--r-- | tasks.md | 61 |
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 @@ -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 +*~ @@ -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/`. @@ -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 ``` @@ -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" @@ -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/" @@ -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); @@ -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); @@ -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 |
